diff --git a/.coveragerc b/.coveragerc index 6ad8658c702..70a74e0a356 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,9 @@ omit = homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py + homeassistant/components/airtouch4/__init__.py + homeassistant/components/airtouch4/climate.py + homeassistant/components/airtouch4/const.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/aladdin_connect/* @@ -314,6 +317,12 @@ omit = homeassistant/components/firmata/switch.py homeassistant/components/fitbit/* homeassistant/components/fixer/sensor.py + homeassistant/components/fjaraskupan/__init__.py + homeassistant/components/fjaraskupan/binary_sensor.py + homeassistant/components/fjaraskupan/const.py + homeassistant/components/fjaraskupan/fan.py + homeassistant/components/fjaraskupan/light.py + homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py homeassistant/components/flic/binary_sensor.py @@ -375,6 +384,7 @@ omit = homeassistant/components/google/* homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py + homeassistant/components/google_pubsub/__init__.py homeassistant/components/google_travel_time/__init__.py homeassistant/components/google_travel_time/helpers.py homeassistant/components/google_travel_time/sensor.py @@ -636,6 +646,7 @@ omit = homeassistant/components/modbus/cover.py homeassistant/components/modbus/climate.py homeassistant/components/modbus/modbus.py + homeassistant/components/modbus/sensor.py homeassistant/components/modbus/validators.py homeassistant/components/modem_callerid/sensor.py homeassistant/components/motion_blinds/__init__.py @@ -666,18 +677,21 @@ omit = homeassistant/components/mysensors/helpers.py homeassistant/components/mysensors/light.py homeassistant/components/mysensors/notify.py - homeassistant/components/mysensors/sensor.py homeassistant/components/mysensors/switch.py homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py homeassistant/components/myq/__init__.py homeassistant/components/myq/cover.py + homeassistant/components/myq/light.py 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 + homeassistant/components/neato/hub.py homeassistant/components/neato/sensor.py homeassistant/components/neato/switch.py homeassistant/components/neato/vacuum.py @@ -696,7 +710,8 @@ omit = homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py homeassistant/components/nissan_leaf/* - homeassistant/components/nmap_tracker/* + homeassistant/components/nmap_tracker/__init__.py + homeassistant/components/nmap_tracker/device_tracker.py homeassistant/components/nmbs/sensor.py homeassistant/components/notion/__init__.py homeassistant/components/notion/binary_sensor.py @@ -830,9 +845,9 @@ omit = homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py homeassistant/components/rainmachine/binary_sensor.py + homeassistant/components/rainmachine/model.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py - homeassistant/components/rainforest_eagle/sensor.py homeassistant/components/raspihats/* homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/__init__.py @@ -850,12 +865,8 @@ omit = homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py homeassistant/components/rituals_perfume_genie/binary_sensor.py - homeassistant/components/rituals_perfume_genie/entity.py homeassistant/components/rituals_perfume_genie/number.py homeassistant/components/rituals_perfume_genie/select.py - homeassistant/components/rituals_perfume_genie/sensor.py - homeassistant/components/rituals_perfume_genie/switch.py - homeassistant/components/rituals_perfume_genie/__init__.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py @@ -893,7 +904,9 @@ omit = homeassistant/components/screenlogic/switch.py homeassistant/components/scsgate/* homeassistant/components/sendgrid/notify.py - homeassistant/components/sense/* + homeassistant/components/sense/__init__.py + homeassistant/components/sense/binary_sensor.py + homeassistant/components/sense/sensor.py homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py homeassistant/components/sensibo/climate.py @@ -988,6 +1001,7 @@ omit = homeassistant/components/suez_water/* homeassistant/components/supervisord/sensor.py homeassistant/components/surepetcare/__init__.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 @@ -1008,8 +1022,9 @@ omit = homeassistant/components/synology_srm/device_tracker.py homeassistant/components/syslog/notify.py homeassistant/components/system_bridge/__init__.py - homeassistant/components/system_bridge/const.py homeassistant/components/system_bridge/binary_sensor.py + homeassistant/components/system_bridge/const.py + homeassistant/components/system_bridge/coordinator.py homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/* @@ -1079,6 +1094,10 @@ omit = 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/device_tracker.py + homeassistant/components/tractive/entity.py + homeassistant/components/tractive/sensor.py homeassistant/components/tradfri/* homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1112,7 +1131,6 @@ omit = homeassistant/components/upcloud/switch.py homeassistant/components/upnp/* homeassistant/components/upc_connect/* - homeassistant/components/uptimerobot/binary_sensor.py homeassistant/components/uscis/sensor.py homeassistant/components/vallox/* homeassistant/components/vasttrafik/sensor.py @@ -1196,6 +1214,7 @@ omit = homeassistant/components/xiaomi_miio/__init__.py homeassistant/components/xiaomi_miio/air_quality.py homeassistant/components/xiaomi_miio/alarm_control_panel.py + homeassistant/components/xiaomi_miio/binary_sensor.py homeassistant/components/xiaomi_miio/device.py homeassistant/components/xiaomi_miio/device_tracker.py homeassistant/components/xiaomi_miio/fan.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index efcc0380748..2f94441940e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,7 +24,12 @@ "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, - "terminal.integrated.shell.linux": "/usr/bin/zsh", + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", "yaml.customTags": [ "!input scalar", "!secret scalar", diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7c169580cb2..974022834fb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -71,6 +71,7 @@ If the code communicates with devices, web services, or third-party tools: Updated and included derived files by running: `python3 -m script.hassfest`. - [ ] New or updated dependencies have been added to `requirements_all.txt`. Updated by running `python3 -m script.gen_requirements_all`. +- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description. - [ ] Untested files have been added to `.coveragerc`. The integration reached or maintains the following [Integration Quality Scale][quality-scale]: diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 89a408c3b6a..25d4d0ca8a0 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -47,6 +47,19 @@ jobs: with: ignore-dev: true + - name: Generate meta info + shell: bash + run: | + echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > OFFICIAL_IMAGE + + - name: Signing meta info file + uses: home-assistant/actions/helpers/codenotary@master + with: + source: file://${{ github.workspace }}/OFFICIAL_IMAGE + user: ${{ secrets.VCN_USER }} + password: ${{ secrets.VCN_PASSWORD }} + organisation: home-assistant.io + build_python: name: Build PyPi package needs: init @@ -101,6 +114,11 @@ jobs: python3 script/version_bump.py nightly version="$(python setup.py -V)" + - name: Write meta info file + shell: bash + run: | + echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE + - name: Login to DockerHub uses: docker/login-action@v1.10.0 with: @@ -230,11 +248,12 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Install VCN tools + uses: home-assistant/actions/helpers/vcn@master + - name: Build Meta Image shell: bash run: | - bash <(curl https://getvcn.codenotary.com -L) - export DOCKER_CLI_EXPERIMENTAL=enabled function create_manifest() { diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 06d22228a28..ec7aeb7afb0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -740,4 +740,4 @@ jobs: coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2.0.2 + uses: codecov/codecov-action@v2.0.3 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 62c7299c2b8..96fc69e3b68 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,7 +9,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2.1.1 + - uses: dessant/lock-threads@v2.1.2 with: github-token: ${{ github.token }} issue-lock-inactive-days: "30" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cce57401ea8..95f7f1fda4d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -66,6 +66,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - "3.9-alpine3.13" + - "3.9-alpine3.14" steps: - name: Checkout the repository uses: actions/checkout@v2.3.4 @@ -106,6 +107,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} tag: - "3.9-alpine3.13" + - "3.9-alpine3.14" steps: - name: Checkout the repository uses: actions/checkout@v2.3.4 diff --git a/.gitignore b/.gitignore index 20c1991c45d..bdc4c24c5b0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ config/* config2/* tests/testing_config/deps -tests/testing_config/home-assistant.log +tests/testing_config/home-assistant.log* # hass-release data/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1e36ae652d6..38ba2a503af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.23.0 + rev: v2.23.3 hooks: - id: pyupgrade args: [--py38-plus] @@ -45,7 +45,7 @@ repos: - --configfile=tests/bandit.yaml files: ^(homeassistant|script|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.8.0 + rev: 5.9.3 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/.strict-typing b/.strict-typing index 6066c158b99..e0993c2954a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -15,6 +15,7 @@ homeassistant.components.alarm_control_panel.* homeassistant.components.amazon_polly.* homeassistant.components.ambee.* homeassistant.components.ambient_station.* +homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.automation.* homeassistant.components.binary_sensor.* @@ -63,6 +64,7 @@ homeassistant.components.mailbox.* homeassistant.components.media_player.* homeassistant.components.mysensors.* homeassistant.components.nam.* +homeassistant.components.neato.* homeassistant.components.nest.* homeassistant.components.netatmo.* homeassistant.components.network.* @@ -103,6 +105,7 @@ homeassistant.components.tile.* homeassistant.components.tts.* homeassistant.components.upcloud.* homeassistant.components.uptime.* +homeassistant.components.uptimerobot.* homeassistant.components.vacuum.* homeassistant.components.water_heater.* homeassistant.components.weather.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 24d643b96bc..5488c3472de 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -60,6 +60,21 @@ }, "problemMatcher": [] }, + { + "label": "Code Coverage", + "detail": "Generate code coverage report for a given integration.", + "type": "shell", + "command": "pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, { "label": "Generate Requirements", "type": "shell", @@ -102,5 +117,12 @@ }, "problemMatcher": [] } + ], + "inputs": [ + { + "id": "integrationName", + "type": "promptString", + "description": "For which integration should the task run?" + } ] } diff --git a/CODEOWNERS b/CODEOWNERS index 29906631254..a1b12a81127 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/airtouch4/* @LonePurpleWolf homeassistant/components/airvisual/* @bachya homeassistant/components/alarmdecoder/* @ajschmidt8 homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy @@ -37,6 +38,7 @@ homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/ambee/* @frenck homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya +homeassistant/components/amcrest/* @flacjacket homeassistant/components/analytics/* @home-assistant/core @ludeeus homeassistant/components/androidtv/* @JeffLIrion homeassistant/components/apache_kafka/* @bachya @@ -117,7 +119,6 @@ homeassistant/components/dexcom/* @gagebenne homeassistant/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey homeassistant/components/digital_ocean/* @fabaff -homeassistant/components/directv/* @ctalkington homeassistant/components/discogs/* @thibmaek homeassistant/components/doorbird/* @oblogic7 @bdraco homeassistant/components/dsmr/* @Robbie1221 @frenck @@ -161,6 +162,7 @@ homeassistant/components/filter/* @dgomes homeassistant/components/fireservicerota/* @cyberjunky homeassistant/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff +homeassistant/components/fjaraskupan/* @elupus homeassistant/components/flick_electric/* @ZephireNZ homeassistant/components/flipr/* @cnico homeassistant/components/flo/* @dmulcahey @@ -186,6 +188,7 @@ homeassistant/components/geo_rss_events/* @exxamalte homeassistant/components/geonetnz_quakes/* @exxamalte homeassistant/components/geonetnz_volcano/* @exxamalte homeassistant/components/gios/* @bieniu +homeassistant/components/github/* @timmo001 @ludeeus homeassistant/components/gitter/* @fabaff homeassistant/components/glances/* @fabaff @engrbm87 homeassistant/components/goalzero/* @tkdrob @@ -196,7 +199,7 @@ homeassistant/components/gpsd/* @fabaff homeassistant/components/gree/* @cmroche homeassistant/components/greeneye_monitor/* @jkeljo homeassistant/components/group/* @home-assistant/core -homeassistant/components/growatt_server/* @indykoning @muppet3000 +homeassistant/components/growatt_server/* @indykoning @muppet3000 @JasperPlant homeassistant/components/guardian/* @bachya homeassistant/components/habitica/* @ASMfreaK @leikoilja homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey @@ -245,6 +248,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/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis homeassistant/components/ipp/* @ctalkington @@ -319,10 +323,11 @@ homeassistant/components/msteams/* @peroyvind homeassistant/components/mullvad/* @meichthys homeassistant/components/mutesync/* @currentoor homeassistant/components/my/* @home-assistant/core -homeassistant/components/myq/* @bdraco +homeassistant/components/myq/* @bdraco @ehendrix23 homeassistant/components/mysensors/* @MartinHjelmare @functionpointer homeassistant/components/mystrom/* @fabaff homeassistant/components/nam/* @bieniu +homeassistant/components/nanoleaf/* @milanmeu homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/nederlandse_spoorwegen/* @YarmoM homeassistant/components/nello/* @pschmitt @@ -337,6 +342,7 @@ homeassistant/components/nfandroidtv/* @tkdrob homeassistant/components/nightscout/* @marciogranzotto homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole +homeassistant/components/nmap_tracker/* @bdraco homeassistant/components/nmbs/* @thibmaek homeassistant/components/no_ip/* @fabaff homeassistant/components/noaa_tides/* @jdelaney72 @@ -370,6 +376,7 @@ homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu homeassistant/components/ovo_energy/* @timmo001 homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare +homeassistant/components/p1_monitor/* @klaasnicolaas homeassistant/components/panel_custom/* @home-assistant/frontend homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/pcal9535a/* @Shulyaka @@ -502,7 +509,7 @@ homeassistant/components/synology_dsm/* @hacf-fr @Quentame @mib1185 homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff homeassistant/components/system_bridge/* @timmo001 -homeassistant/components/tado/* @michaelarnauts @bdraco @noltari +homeassistant/components/tado/* @michaelarnauts @noltari homeassistant/components/tag/* @balloob @dmulcahey homeassistant/components/tahoma/* @philklei homeassistant/components/tankerkoenig/* @guillempages @@ -525,6 +532,8 @@ homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/traccar/* @ludeeus homeassistant/components/trace/* @home-assistant/core +homeassistant/components/tractive/* @Danielhiversen @zhulik @bieniu +homeassistant/components/tradfri/* @janiversen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins @@ -539,8 +548,9 @@ homeassistant/components/upb/* @gwww homeassistant/components/upc_connect/* @pvizeli @fabaff homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core -homeassistant/components/upnp/* @StevenLooman +homeassistant/components/upnp/* @StevenLooman @ehendrix23 homeassistant/components/uptimerobot/* @ludeeus +homeassistant/components/usb/* @bdraco homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes homeassistant/components/velbus/* @Cereal2nd @brefra @@ -576,13 +586,13 @@ homeassistant/components/worldclock/* @fabaff homeassistant/components/xbox/* @hunterjm homeassistant/components/xbox_live/* @MartinHjelmare homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi -homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG +homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG @bieniu homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yale_smart_alarm/* @gjohansson-ST homeassistant/components/yamaha_musiccast/* @vigonotion @micha91 homeassistant/components/yandex_transport/* @rishatik92 @devbis -homeassistant/components/yeelight/* @rytilahti @zewelor @shenxn +homeassistant/components/yeelight/* @rytilahti @zewelor @shenxn @starkillerOG homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yi/* @bachya homeassistant/components/youless/* @gjong diff --git a/build.json b/build.json index 006d182c99d..bdb59943d72 100644 --- a/build.json +++ b/build.json @@ -2,11 +2,11 @@ "image": "homeassistant/{arch}-homeassistant", "shadow_repository": "ghcr.io/home-assistant", "build_from": { - "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.07.0", - "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.07.0", - "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.07.0", - "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.07.0", - "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.07.0" + "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.08.0", + "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.08.0", + "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.08.0", + "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.08.0", + "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.08.0" }, "labels": { "io.hass.type": "core", diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 0b360668ad4..63cbeb1bf7e 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -17,6 +17,8 @@ from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY, GROUP_ID_USER from .permissions import PermissionLookup, system_policies from .permissions.types import PolicyType +# mypy: disallow-any-generics + STORAGE_VERSION = 1 STORAGE_KEY = "auth" GROUP_NAME_ADMIN = "Administrators" @@ -491,7 +493,7 @@ class AuthStore: self._store.async_delay_save(self._data_to_save, 1) @callback - def _data_to_save(self) -> dict: + def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: """Return the data to store.""" assert self._users is not None assert self._groups is not None diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index d2dfa0e1c6d..4faa277a081 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -22,6 +22,8 @@ from ..auth_store import AuthStore from ..const import MFA_SESSION_EXPIRATION from ..models import Credentials, RefreshToken, User, UserMeta +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) DATA_REQS = "auth_prov_reqs_processed" @@ -96,7 +98,7 @@ class AuthProvider: # Implement by extending class - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return the data flow for logging in with auth provider. Auth provider should extend LoginFlow and return an instance. diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index f462ad4be9d..6d1a1627fd5 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -17,6 +17,8 @@ from homeassistant.exceptions import HomeAssistantError from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + CONF_ARGS = "args" CONF_META = "meta" @@ -56,7 +58,7 @@ class CommandLineAuthProvider(AuthProvider): super().__init__(*args, **kwargs) self._user_meta: dict[str, dict[str, Any]] = {} - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return CommandLineLoginFlow(self) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index dfbf077a89d..b08c59bf3aa 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -19,6 +19,8 @@ from homeassistant.exceptions import HomeAssistantError from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + STORAGE_VERSION = 1 STORAGE_KEY = "auth_provider.homeassistant" @@ -235,7 +237,7 @@ class HassAuthProvider(AuthProvider): await data.async_load() self.data = data - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return HassLoginFlow(self) diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index 5a3a890ff66..fb390b65b0d 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Mapping import hmac -from typing import cast +from typing import Any, cast import voluptuous as vol @@ -15,6 +15,8 @@ from homeassistant.exceptions import HomeAssistantError from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + USER_SCHEMA = vol.Schema( { vol.Required("username"): str, @@ -37,7 +39,7 @@ class InvalidAuthError(HomeAssistantError): class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return ExampleLoginFlow(self) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index b385aa0ed59..af24506210b 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -7,7 +7,7 @@ from __future__ import annotations from collections.abc import Mapping import hmac -from typing import cast +from typing import Any, cast import voluptuous as vol @@ -19,6 +19,8 @@ import homeassistant.helpers.config_validation as cv from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +# mypy: disallow-any-generics + AUTH_PROVIDER_TYPE = "legacy_api_password" CONF_API_PASSWORD = "api_password" @@ -44,7 +46,7 @@ class LegacyApiPasswordAuthProvider(AuthProvider): """Return api_password.""" return str(self.config[CONF_API_PASSWORD]) - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" return LegacyLoginFlow(self) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 7b609f371ef..a9ee6a48335 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -27,6 +27,8 @@ from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from .. import InvalidAuthError from ..models import Credentials, RefreshToken, UserMeta +# mypy: disallow-any-generics + IPAddress = Union[IPv4Address, IPv6Address] IPNetwork = Union[IPv4Network, IPv6Network] @@ -97,7 +99,7 @@ class TrustedNetworksAuthProvider(AuthProvider): """Trusted Networks auth provider does not support MFA.""" return False - async def async_login_flow(self, context: dict | None) -> LoginFlow: + async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: """Return a flow to login.""" assert context is not None ip_addr = cast(IPAddress, context.get("ip_address")) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 45c04651461..f1136123999 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -332,15 +332,17 @@ def async_enable_logging( not err_path_exists and os.access(err_dir, os.W_OK) ): + err_handler: logging.handlers.RotatingFileHandler | logging.handlers.TimedRotatingFileHandler if log_rotate_days: - err_handler: logging.FileHandler = ( - logging.handlers.TimedRotatingFileHandler( - err_log_path, when="midnight", backupCount=log_rotate_days - ) + err_handler = logging.handlers.TimedRotatingFileHandler( + err_log_path, when="midnight", backupCount=log_rotate_days ) else: - err_handler = logging.FileHandler(err_log_path, mode="w", delay=True) + err_handler = logging.handlers.RotatingFileHandler( + err_log_path, backupCount=1 + ) + err_handler.doRollover() err_handler.setLevel(logging.INFO if verbose else logging.WARNING) err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 99d4fd433a7..987e32f9911 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -1,4 +1,6 @@ """Support for Abode Security System cameras.""" +from __future__ import annotations + from datetime import timedelta import abodepy.helpers.constants as CONST @@ -73,7 +75,9 @@ class AbodeCamera(AbodeDevice, Camera): else: self._response = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get a camera image.""" self.refresh_image() diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index f1f744a5511..03687fc3907 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -1,7 +1,9 @@ """Support for Abode Security System sensors.""" +from __future__ import annotations + import abodepy.helpers.constants as CONST -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -11,12 +13,23 @@ from homeassistant.const import ( from . import AbodeDevice from .const import DOMAIN -# Sensor types: Name, icon -SENSOR_TYPES = { - CONST.TEMP_STATUS_KEY: ["Temperature", DEVICE_CLASS_TEMPERATURE], - CONST.HUMI_STATUS_KEY: ["Humidity", DEVICE_CLASS_HUMIDITY], - CONST.LUX_STATUS_KEY: ["Lux", DEVICE_CLASS_ILLUMINANCE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=CONST.TEMP_STATUS_KEY, + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=CONST.HUMI_STATUS_KEY, + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=CONST.LUX_STATUS_KEY, + name="Lux", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -26,10 +39,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR): - for sensor_type in SENSOR_TYPES: - if sensor_type not in device.get_value(CONST.STATUSES_KEY): - continue - entities.append(AbodeSensor(data, device, sensor_type)) + conditions = device.get_value(CONST.STATUSES_KEY) + entities.extend( + [ + AbodeSensor(data, device, description) + for description in SENSOR_TYPES + if description.key in conditions + ] + ) async_add_entities(entities) @@ -37,26 +54,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AbodeSensor(AbodeDevice, SensorEntity): """A sensor implementation for Abode devices.""" - def __init__(self, data, device, sensor_type): + def __init__(self, data, device, description: SensorEntityDescription): """Initialize a sensor for an Abode device.""" super().__init__(data, device) - self._sensor_type = sensor_type - self._attr_name = f"{device.name} {SENSOR_TYPES[sensor_type][0]}" - self._attr_device_class = SENSOR_TYPES[self._sensor_type][1] - self._attr_unique_id = f"{device.device_uuid}-{sensor_type}" - if self._sensor_type == CONST.TEMP_STATUS_KEY: - self._attr_unit_of_measurement = device.temp_unit - elif self._sensor_type == CONST.HUMI_STATUS_KEY: - self._attr_unit_of_measurement = device.humidity_unit - elif self._sensor_type == CONST.LUX_STATUS_KEY: - self._attr_unit_of_measurement = device.lux_unit + self.entity_description = description + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{device.device_uuid}-{description.key}" + if description.key == CONST.TEMP_STATUS_KEY: + self._attr_native_unit_of_measurement = device.temp_unit + elif description.key == CONST.HUMI_STATUS_KEY: + self._attr_native_unit_of_measurement = device.humidity_unit + elif description.key == CONST.LUX_STATUS_KEY: + self._attr_native_unit_of_measurement = device.lux_unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - if self._sensor_type == CONST.TEMP_STATUS_KEY: + if self.entity_description.key == CONST.TEMP_STATUS_KEY: return self._device.temp - if self._sensor_type == CONST.HUMI_STATUS_KEY: + if self.entity_description.key == CONST.HUMI_STATUS_KEY: return self._device.humidity - if self._sensor_type == CONST.LUX_STATUS_KEY: + if self.entity_description.key == CONST.LUX_STATUS_KEY: return self._device.lux diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 4a5af6054e1..b5f979b45cf 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -88,10 +88,10 @@ class AccuWeatherSensor(CoordinatorEntity, SensorEntity): ) if coordinator.is_metric: self._unit_system = API_METRIC - self._attr_unit_of_measurement = description.unit_metric + self._attr_native_unit_of_measurement = description.unit_metric else: self._unit_system = API_IMPERIAL - self._attr_unit_of_measurement = description.unit_imperial + self._attr_native_unit_of_measurement = description.unit_imperial self._attr_device_info = { "identifiers": {(DOMAIN, coordinator.location_key)}, "name": NAME, @@ -101,7 +101,7 @@ class AccuWeatherSensor(CoordinatorEntity, SensorEntity): self.forecast_day = forecast_day @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" if self.forecast_day is not None: if self.entity_description.device_class == DEVICE_CLASS_TEMPERATURE: diff --git a/homeassistant/components/accuweather/translations/he.json b/homeassistant/components/accuweather/translations/he.json index 219ce00872f..77c1e54f3e5 100644 --- a/homeassistant/components/accuweather/translations/he.json +++ b/homeassistant/components/accuweather/translations/he.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", - "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "requests_exceeded": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05d7\u05e8\u05d9\u05d2\u05d4 \u05de\u05de\u05e1\u05e4\u05e8 \u05d4\u05d1\u05e7\u05e9\u05d5\u05ea \u05d4\u05de\u05d5\u05ea\u05e8 \u05dc-API \u05e9\u05dc Accuweather. \u05e2\u05dc\u05d9\u05da \u05dc\u05d4\u05de\u05ea\u05d9\u05df \u05d0\u05d5 \u05dc\u05e9\u05e0\u05d5\u05ea \u05d0\u05ea \u05de\u05e4\u05ea\u05d7 \u05d4-API." }, "step": { "user": { @@ -15,6 +16,7 @@ "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", "name": "\u05e9\u05dd" }, + "description": "\u05d0\u05dd \u05d4\u05d9\u05e0\u05da \u05d6\u05e7\u05d5\u05e7 \u05dc\u05e2\u05d6\u05e8\u05d4 \u05e2\u05dd \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4, \u05d9\u05e9 \u05dc\u05e2\u05d9\u05d9\u05df \u05db\u05d0\u05df: https://www.home-assistant.io/integrations/accuweather/\n\n\u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05de\u05e1\u05d5\u05d9\u05de\u05d9\u05dd \u05d0\u05d9\u05e0\u05dd \u05d6\u05de\u05d9\u05e0\u05d9\u05dd \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05d4\u05e4\u05d5\u05da \u05d0\u05d5\u05ea\u05dd \u05dc\u05d6\u05de\u05d9\u05e0\u05d9\u05dd \u05d1\u05e8\u05d9\u05e9\u05d5\u05dd \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05dc\u05d0\u05d7\u05e8 \u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.\n\u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d4\u05d0\u05d5\u05d5\u05d9\u05e8 \u05d0\u05d9\u05e0\u05d4 \u05d6\u05de\u05d9\u05e0\u05d4 \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05d4\u05e4\u05d5\u05da \u05d0\u05d5\u05ea\u05d5 \u05dc\u05d6\u05de\u05d9\u05df \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.", "title": "AccuWeather" } } @@ -22,6 +24,9 @@ "options": { "step": { "user": { + "data": { + "forecast": "\u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d4\u05d0\u05d5\u05d5\u05d9\u05e8" + }, "description": "\u05d1\u05e9\u05dc \u05de\u05d2\u05d1\u05dc\u05d5\u05ea \u05d4\u05d2\u05d9\u05e8\u05e1\u05d4 \u05d4\u05d7\u05d9\u05e0\u05de\u05d9\u05ea \u05e9\u05dc \u05de\u05e4\u05ea\u05d7 \u05d4-API \u05e9\u05dc AccuWeather, \u05db\u05d0\u05e9\u05e8 \u05ea\u05e4\u05e2\u05d9\u05dc \u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d0\u05d5\u05d5\u05d9\u05e8, \u05e2\u05d3\u05db\u05d5\u05e0\u05d9 \u05e0\u05ea\u05d5\u05e0\u05d9\u05dd \u05d9\u05d1\u05d5\u05e6\u05e2\u05d5 \u05db\u05dc 80 \u05d3\u05e7\u05d5\u05ea \u05d1\u05de\u05e7\u05d5\u05dd \u05db\u05dc 40 \u05d3\u05e7\u05d5\u05ea.", "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea AccuWeather" } @@ -29,7 +34,8 @@ }, "system_health": { "info": { - "can_reach_server": "\u05d4\u05e9\u05d2\u05ea \u05e9\u05e8\u05ea AccuWeather" + "can_reach_server": "\u05d4\u05e9\u05d2\u05ea \u05e9\u05e8\u05ea AccuWeather", + "remaining_requests": "\u05d4\u05d1\u05e7\u05e9\u05d5\u05ea \u05d4\u05e0\u05d5\u05ea\u05e8\u05d5\u05ea \u05de\u05d5\u05ea\u05e8\u05d5\u05ea" } } } \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/hu.json b/homeassistant/components/accuweather/translations/hu.json index ce4721693f3..7b4d270f78b 100644 --- a/homeassistant/components/accuweather/translations/hu.json +++ b/homeassistant/components/accuweather/translations/hu.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs" + "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." }, "step": { "user": { @@ -15,6 +16,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, + "description": "Ha seg\u00edts\u00e9gre van sz\u00fcks\u00e9ge a konfigur\u00e1l\u00e1shoz, n\u00e9zze meg itt: https://www.home-assistant.io/integrations/accuweather/ \n\nEgyes \u00e9rz\u00e9kel\u0151k alap\u00e9rtelmez\u00e9s szerint nincsenek enged\u00e9lyezve. Az integr\u00e1ci\u00f3s konfigur\u00e1ci\u00f3 ut\u00e1n enged\u00e9lyezheti \u0151ket az entit\u00e1s-nyilv\u00e1ntart\u00e1sban.\nAz id\u0151j\u00e1r\u00e1s-el\u0151rejelz\u00e9s alap\u00e9rtelmez\u00e9s szerint nincs enged\u00e9lyezve. Ezt az integr\u00e1ci\u00f3s be\u00e1ll\u00edt\u00e1sokban enged\u00e9lyezheti.", "title": "AccuWeather" } } @@ -22,6 +24,10 @@ "options": { "step": { "user": { + "data": { + "forecast": "Id\u0151j\u00e1r\u00e1s el\u0151rejelz\u00e9s" + }, + "description": "Az AccuWeather API kulcs ingyenes verzi\u00f3j\u00e1nak korl\u00e1tai miatt, amikor enged\u00e9lyezi az id\u0151j\u00e1r\u00e1s -el\u0151rejelz\u00e9st, az adatfriss\u00edt\u00e9seket 40 percenk\u00e9nt 80 percenk\u00e9nt hajtj\u00e1k v\u00e9gre.", "title": "AccuWeather be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/accuweather/translations/sensor.he.json b/homeassistant/components/accuweather/translations/sensor.he.json new file mode 100644 index 00000000000..08c637f1ce1 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.he.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u05d9\u05d5\u05e8\u05d3", + "rising": "\u05e2\u05d5\u05dc\u05d4", + "steady": "\u05d9\u05e6\u05d9\u05d1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 82c61202cd3..6c1de528abe 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -61,7 +61,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): None is unknown, 0 is closed, 100 is fully open. """ position = None - if self.roller.type in [7, 10]: + if self.roller.type in (7, 10): position = 100 - self.roller.closed_percent return position diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index 4f617c5726f..43f5e32c74f 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -34,7 +34,7 @@ class AcmedaBattery(AcmedaBase, SensorEntity): """Representation of a Acmeda cover device.""" device_class = DEVICE_CLASS_BATTERY - unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property def name(self): @@ -42,6 +42,6 @@ class AcmedaBattery(AcmedaBase, SensorEntity): return f"{super().name} Battery" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.roller.battery diff --git a/homeassistant/components/acmeda/translations/hu.json b/homeassistant/components/acmeda/translations/hu.json index 6105977de80..f302995e7e9 100644 --- a/homeassistant/components/acmeda/translations/hu.json +++ b/homeassistant/components/acmeda/translations/hu.json @@ -2,6 +2,14 @@ "config": { "abort": { "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "step": { + "user": { + "data": { + "id": "Gazdag\u00e9p azonos\u00edt\u00f3" + }, + "title": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt hubot" + } } } } \ No newline at end of file diff --git a/homeassistant/components/actiontec/const.py b/homeassistant/components/actiontec/const.py index 1043bd1bdb6..de309b68476 100644 --- a/homeassistant/components/actiontec/const.py +++ b/homeassistant/components/actiontec/const.py @@ -4,7 +4,9 @@ from __future__ import annotations import re from typing import Final -LEASES_REGEX: Final[re.Pattern] = re.compile( +# mypy: disallow-any-generics + +LEASES_REGEX: Final[re.Pattern[str]] = re.compile( r"(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})" + r"\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))" + r"\svalid\sfor:\s(?P(-?\d+))" diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 74e973ba6d5..1abd83fdbfc 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -49,20 +49,19 @@ async def async_setup_entry( class AdaxDevice(ClimateEntity): """Representation of a heater.""" + _attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + _attr_max_temp = 35 + _attr_min_temp = 5 + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = TEMP_CELSIUS + def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: """Initialize the heater.""" self._heater_data = heater_data self._adax_data_handler = adax_data_handler - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._heater_data['homeId']}_{self._heater_data['id']}" + self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}" @property def name(self) -> str: @@ -83,11 +82,6 @@ class AdaxDevice(ClimateEntity): return "mdi:radiator" return "mdi:radiator-off" - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] - async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set hvac mode.""" if hvac_mode == HVAC_MODE_HEAT: @@ -105,21 +99,6 @@ class AdaxDevice(ClimateEntity): return await self._adax_data_handler.update() - @property - def temperature_unit(self) -> str: - """Return the unit of measurement which this device uses.""" - return TEMP_CELSIUS - - @property - def min_temp(self) -> int: - """Return the minimum temperature.""" - return 5 - - @property - def max_temp(self) -> int: - """Return the maximum temperature.""" - return 35 - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -130,11 +109,6 @@ class AdaxDevice(ClimateEntity): """Return the temperature we try to reach.""" return self._heater_data.get("targetTemperature") - @property - def target_temperature_step(self) -> int: - """Return the supported step of target temperature.""" - return PRECISION_WHOLE - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) diff --git a/homeassistant/components/adax/translations/cs.json b/homeassistant/components/adax/translations/cs.json index ce5fa77543f..1d090f44de2 100644 --- a/homeassistant/components/adax/translations/cs.json +++ b/homeassistant/components/adax/translations/cs.json @@ -10,6 +10,7 @@ "step": { "user": { "data": { + "account_id": "ID \u00fa\u010dtu", "host": "Hostitel", "password": "Heslo" } diff --git a/homeassistant/components/adax/translations/es.json b/homeassistant/components/adax/translations/es.json new file mode 100644 index 00000000000..4a65e469bcd --- /dev/null +++ b/homeassistant/components/adax/translations/es.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "account_id": "ID de la cuenta" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/hu.json b/homeassistant/components/adax/translations/hu.json new file mode 100644 index 00000000000..726381a4dd7 --- /dev/null +++ b/homeassistant/components/adax/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "user": { + "data": { + "account_id": "Fi\u00f3k ID", + "host": "Gazdag\u00e9p", + "password": "Jelsz\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/no.json b/homeassistant/components/adax/translations/no.json new file mode 100644 index 00000000000..33c54b57093 --- /dev/null +++ b/homeassistant/components/adax/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "user": { + "data": { + "account_id": "Konto-ID", + "host": "Vert", + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/zh-Hans.json b/homeassistant/components/adax/translations/zh-Hans.json new file mode 100644 index 00000000000..7356ec08b15 --- /dev/null +++ b/homeassistant/components/adax/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 8f0c73a8a64..eedc1fe4b03 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -198,7 +198,7 @@ class AdGuardHomeDeviceEntity(AdGuardHomeEntity): """Return device information about this AdGuard Home instance.""" return { "identifiers": { - (DOMAIN, self.adguard.host, self.adguard.port, self.adguard.base_path) + (DOMAIN, self.adguard.host, self.adguard.port, self.adguard.base_path) # type: ignore }, "name": "AdGuard Home", "manufacturer": "AdGuard Team", diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py index bbb6d34954b..aa85345179e 100644 --- a/homeassistant/components/adguard/config_flow.py +++ b/homeassistant/components/adguard/config_flow.py @@ -51,6 +51,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN): self, errors: dict[str, str] | None = None ) -> FlowResult: """Show the Hass.io confirmation form to the user.""" + assert self._hassio_discovery return self.async_show_form( step_id="hassio_confirm", description_placeholders={"addon": self._hassio_discovery["addon"]}, @@ -73,11 +74,13 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + username: str | None = user_input.get(CONF_USERNAME) + password: str | None = user_input.get(CONF_PASSWORD) adguard = AdGuardHome( user_input[CONF_HOST], port=user_input[CONF_PORT], - username=user_input.get(CONF_USERNAME), - password=user_input.get(CONF_PASSWORD), + username=username, # type:ignore[arg-type] + password=password, # type:ignore[arg-type] tls=user_input[CONF_SSL], verify_ssl=user_input[CONF_VERIFY_SSL], session=session, @@ -122,6 +125,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass, False) + assert self._hassio_discovery adguard = AdGuardHome( self._hassio_discovery[CONF_HOST], port=self._hassio_discovery[CONF_PORT], diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 7499cf51d0c..8134d2c4d43 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -62,7 +62,7 @@ class AdGuardHomeSensor(AdGuardHomeDeviceEntity, SensorEntity): enabled_default: bool = True, ) -> None: """Initialize AdGuard Home sensor.""" - self._state = None + self._state: int | str | None = None self._unit_of_measurement = unit_of_measurement self.measurement = measurement @@ -82,12 +82,12 @@ class AdGuardHomeSensor(AdGuardHomeDeviceEntity, SensorEntity): ) @property - def state(self) -> str | None: + def native_value(self) -> int | str | None: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/adguard/translations/hu.json b/homeassistant/components/adguard/translations/hu.json index 22fb5539bfa..8a860caf79d 100644 --- a/homeassistant/components/adguard/translations/hu.json +++ b/homeassistant/components/adguard/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "existing_instance_updated": "Friss\u00edtette a megl\u00e9v\u0151 konfigur\u00e1ci\u00f3t." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" @@ -19,7 +20,8 @@ "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" - } + }, + "description": "\u00c1ll\u00edtsa be az AdGuard Home p\u00e9ld\u00e1nyt, hogy lehet\u0151v\u00e9 tegye a fel\u00fcgyeletet \u00e9s az ir\u00e1ny\u00edt\u00e1st." } } } diff --git a/homeassistant/components/adguard/translations/zh-Hans.json b/homeassistant/components/adguard/translations/zh-Hans.json index 4204beb5268..ee68ce83e91 100644 --- a/homeassistant/components/adguard/translations/zh-Hans.json +++ b/homeassistant/components/adguard/translations/zh-Hans.json @@ -1,14 +1,23 @@ { "config": { "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", "existing_instance_updated": "\u66f4\u65b0\u4e86\u73b0\u6709\u914d\u7f6e\u3002" }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, "step": { "user": { "data": { + "host": "\u4e3b\u673a\u5730\u5740", "password": "\u5bc6\u7801", - "username": "\u7528\u6237\u540d" - } + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66\u51ed\u8bc1", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66\u51ed\u8bc1" + }, + "description": "\u8bbe\u7f6e\u60a8\u7684 AdGuard Home \u5b9e\u4f8b\u4ee5\u5141\u8bb8\u76d1\u89c6\u548c\u63a7\u5236" } } } diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index fe68c4c860b..26b04d86050 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -50,7 +50,7 @@ class AdsSensor(AdsEntity, SensorEntity): def __init__(self, ads_hub, ads_var, ads_type, name, unit_of_measurement, factor): """Initialize AdsSensor entity.""" super().__init__(ads_hub, name, ads_var) - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._ads_type = ads_type self._factor = factor @@ -64,6 +64,6 @@ class AdsSensor(AdsEntity, SensorEntity): ) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the device.""" return self._state_dict[STATE_KEY_STATE] diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index eca7651d6eb..4f3258e824e 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -1,7 +1,11 @@ """Sensor platform for Advantage Air integration.""" import voluptuous as vol -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform @@ -45,7 +49,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirTimeTo(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air timer control.""" - _attr_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT + _attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT def __init__(self, instance, ac_key, action): """Initialize the Advantage Air timer control.""" @@ -58,7 +62,7 @@ class AdvantageAirTimeTo(AdvantageAirEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the current value.""" return self._ac[self._time_key] @@ -78,7 +82,7 @@ class AdvantageAirTimeTo(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone Vent Sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, instance, ac_key, zone_key): @@ -90,7 +94,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the current value of the air vent.""" if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: return self._zone["value"] @@ -107,19 +111,19 @@ class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone wireless signal sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, instance, ac_key, zone_key): """Initialize an Advantage Air Zone wireless signal sensor.""" - super().__init__(instance, ac_key, zone_key=zone_key) + super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} Signal' self._attr_unique_id = ( f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-signal' ) @property - def state(self): + def native_value(self): """Return the current value of the wireless signal.""" return self._zone["rssi"] @@ -138,20 +142,22 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): - """Representation of Advantage Air Zone wireless signal sensor.""" + """Representation of Advantage Air Zone temperature sensor.""" - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_icon = "mdi:thermometer" _attr_entity_registry_enabled_default = False def __init__(self, instance, ac_key, zone_key): """Initialize an Advantage Air Zone Temp Sensor.""" super().__init__(instance, ac_key, zone_key) self._attr_name = f'{self._zone["name"]} Temperature' - self._attr_unique_id = f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-temp' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-temp' + ) @property - def state(self): + def native_value(self): """Return the current value of the measured temperature.""" return self._zone["measuredTemp"] diff --git a/homeassistant/components/advantage_air/translations/es-419.json b/homeassistant/components/advantage_air/translations/es-419.json index f2f9a463527..502e9e00ddb 100644 --- a/homeassistant/components/advantage_air/translations/es-419.json +++ b/homeassistant/components/advantage_air/translations/es-419.json @@ -2,6 +2,7 @@ "config": { "step": { "user": { + "description": "Con\u00e9ctese a la API de su tableta de pared Advantage Air.", "title": "Conectar" } } diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 3fd0769cb00..35336980e1a 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -85,7 +85,7 @@ class AbstractAemetSensor(CoordinatorEntity, SensorEntity): self._attr_name = f"{self._name} {self._sensor_name}" self._attr_unique_id = self._unique_id self._attr_device_class = sensor_configuration.get(SENSOR_DEVICE_CLASS) - self._attr_unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) + self._attr_native_unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) class AemetSensor(AbstractAemetSensor): @@ -106,7 +106,7 @@ class AemetSensor(AbstractAemetSensor): self._weather_coordinator = weather_coordinator @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._weather_coordinator.data.get(self._sensor_type) @@ -134,7 +134,7 @@ class AemetForecastSensor(AbstractAemetSensor): ) @property - def state(self): + def native_value(self): """Return the state of the device.""" forecast = None forecasts = self._weather_coordinator.data.get( diff --git a/homeassistant/components/aemet/translations/es-419.json b/homeassistant/components/aemet/translations/es-419.json index 4b3db0a8833..3a02d682f34 100644 --- a/homeassistant/components/aemet/translations/es-419.json +++ b/homeassistant/components/aemet/translations/es-419.json @@ -5,8 +5,18 @@ "data": { "name": "Nombre de la integraci\u00f3n" }, + "description": "Configure la integraci\u00f3n de AEMET OpenData. Para generar la clave API vaya a https://opendata.aemet.es/centrodedescargas/altaUsuario", "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Recopile datos de las estaciones meteorol\u00f3gicas de AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 8259baf9984..77f4a593fb0 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -1,4 +1,6 @@ """Weather data coordinator for the AEMET OpenData service.""" +from __future__ import annotations + from dataclasses import dataclass, field from datetime import timedelta import logging @@ -95,7 +97,7 @@ def format_condition(condition: str) -> str: return condition -def format_float(value) -> float: +def format_float(value) -> float | None: """Try converting string to float.""" try: return float(value) @@ -103,7 +105,7 @@ def format_float(value) -> float: return None -def format_int(value) -> int: +def format_int(value) -> int | None: """Try converting string to int.""" try: return int(value) diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index fd8e095f65f..a3b41f8314c 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -109,7 +109,7 @@ async def async_setup_platform( class AfterShipSensor(SensorEntity): """Representation of a AfterShip sensor.""" - _attr_unit_of_measurement: str = "packages" + _attr_native_unit_of_measurement: str = "packages" _attr_icon: str = ICON def __init__(self, aftership: Tracking, name: str) -> None: @@ -120,7 +120,7 @@ class AfterShipSensor(SensorEntity): self._attr_name = name @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/agent_dvr/translations/hu.json b/homeassistant/components/agent_dvr/translations/hu.json index 49968ceea75..fff86517073 100644 --- a/homeassistant/components/agent_dvr/translations/hu.json +++ b/homeassistant/components/agent_dvr/translations/hu.json @@ -12,7 +12,8 @@ "data": { "host": "Hoszt", "port": "Port" - } + }, + "title": "\u00c1ll\u00edtsa be az Agent DVR-t" } } } diff --git a/homeassistant/components/agent_dvr/translations/zh-Hans.json b/homeassistant/components/agent_dvr/translations/zh-Hans.json index 2941dfd9383..68393fce470 100644 --- a/homeassistant/components/agent_dvr/translations/zh-Hans.json +++ b/homeassistant/components/agent_dvr/translations/zh-Hans.json @@ -1,7 +1,20 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, "error": { + "already_in_progress": "\u914d\u7f6e\u6d41\u5df2\u8fdb\u884c\u4e2d", "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3" + }, + "title": "\u914d\u7f6e Agent DVR" + } } } } \ No newline at end of file diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 157f28c33f7..79004abbe41 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -6,7 +6,11 @@ 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, @@ -49,35 +53,36 @@ 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, - unit_of_measurement="CAQI", + native_unit_of_measurement="CAQI", ), AirlySensorEntityDescription( key=ATTR_API_PM1, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM1, name=ATTR_API_PM1, - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), AirlySensorEntityDescription( key=ATTR_API_PM25, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM25, name="PM2.5", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), AirlySensorEntityDescription( key=ATTR_API_PM10, - icon="mdi:blur", + device_class=DEVICE_CLASS_PM10, name=ATTR_API_PM10, - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + 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(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, value=lambda value: round(value, 1), ), @@ -85,14 +90,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_PRESSURE, device_class=DEVICE_CLASS_PRESSURE, name=ATTR_API_PRESSURE.capitalize(), - unit_of_measurement=PRESSURE_HPA, + 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(), - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, state_class=STATE_CLASS_MEASUREMENT, value=lambda value: round(value, 1), ), diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 2c811b00aa6..b5d45afd2d8 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -84,7 +84,7 @@ class AirlySensor(CoordinatorEntity, SensorEntity): self.entity_description = description @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" state = self.coordinator.data[self.entity_description.key] return cast(StateType, self.entity_description.value(state)) diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 31ea5e298e3..a0f8d7e701b 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -72,11 +72,11 @@ class AirNowSensor(CoordinatorEntity, SensorEntity): self._attr_name = f"AirNow {SENSOR_TYPES[self.kind][ATTR_LABEL]}" self._attr_icon = SENSOR_TYPES[self.kind][ATTR_ICON] self._attr_device_class = SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] - self._attr_unit_of_measurement = SENSOR_TYPES[self.kind][ATTR_UNIT] + self._attr_native_unit_of_measurement = SENSOR_TYPES[self.kind][ATTR_UNIT] self._attr_unique_id = f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" @property - def state(self): + def native_value(self): """Return the state.""" self._state = self.coordinator.data[self.kind] return self._state diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py new file mode 100644 index 00000000000..0ec63161ea3 --- /dev/null +++ b/homeassistant/components/airtouch4/__init__.py @@ -0,0 +1,81 @@ +"""The AirTouch4 integration.""" +import logging + +from airtouch4pyapi import AirTouch +from airtouch4pyapi.airtouch import AirTouchStatus + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AirTouch4 from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + host = entry.data[CONF_HOST] + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + info = airtouch.GetAcs() + if not info: + raise ConfigEntryNotReady + coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) + 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 + + +class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Airtouch data.""" + + def __init__(self, hass, airtouch): + """Initialize global Airtouch data updater.""" + self.airtouch = airtouch + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Airtouch.""" + await self.airtouch.UpdateInfo() + if self.airtouch.Status != AirTouchStatus.OK: + raise UpdateFailed("Airtouch connection issue") + return { + "acs": [ + {"ac_number": ac.AcNumber, "is_on": ac.IsOn} + for ac in self.airtouch.GetAcs() + ], + "groups": [ + { + "group_number": group.GroupNumber, + "group_name": group.GroupName, + "is_on": group.IsOn, + } + for group in self.airtouch.GetGroups() + ], + } diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py new file mode 100644 index 00000000000..7202feb0527 --- /dev/null +++ b/homeassistant/components/airtouch4/climate.py @@ -0,0 +1,335 @@ +"""AirTouch 4 component to control of AirTouch 4 Climate Devices.""" + +import logging + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + 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_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE +AT_TO_HA_STATE = { + "Heat": HVAC_MODE_HEAT, + "Cool": HVAC_MODE_COOL, + "AutoHeat": HVAC_MODE_AUTO, # airtouch reports either autoheat or autocool + "AutoCool": HVAC_MODE_AUTO, + "Auto": HVAC_MODE_AUTO, + "Dry": HVAC_MODE_DRY, + "Fan": HVAC_MODE_FAN_ONLY, +} + +HA_STATE_TO_AT = { + HVAC_MODE_HEAT: "Heat", + HVAC_MODE_COOL: "Cool", + HVAC_MODE_AUTO: "Auto", + HVAC_MODE_DRY: "Dry", + HVAC_MODE_FAN_ONLY: "Fan", + HVAC_MODE_OFF: "Off", +} + +AT_TO_HA_FAN_SPEED = { + "Quiet": FAN_DIFFUSE, + "Low": FAN_LOW, + "Medium": FAN_MEDIUM, + "High": FAN_HIGH, + "Powerful": FAN_FOCUS, + "Auto": FAN_AUTO, + "Turbo": "turbo", +} + +AT_GROUP_MODES = [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] + +HA_FAN_SPEED_TO_AT = {value: key for key, value in AT_TO_HA_FAN_SPEED.items()} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Airtouch 4.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + info = coordinator.data + entities = [ + AirtouchGroup(coordinator, group["group_number"], info) + for group in info["groups"] + ] + [AirtouchAC(coordinator, ac["ac_number"], info) for ac in info["acs"]] + + _LOGGER.debug(" Found entities %s", entities) + + async_add_entities(entities) + + +class AirtouchAC(CoordinatorEntity, ClimateEntity): + """Representation of an AirTouch 4 ac.""" + + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, coordinator, ac_number, info): + """Initialize the climate device.""" + super().__init__(coordinator) + self._ac_number = ac_number + self._airtouch = coordinator.airtouch + self._info = info + self._unit = self._airtouch.GetAcs()[self._ac_number] + + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetAcs()[self._ac_number] + return super()._handle_coordinator_update() + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Airtouch", + "model": "Airtouch 4", + } + + @property + def unique_id(self): + """Return unique ID for this device.""" + return f"ac_{self._ac_number}" + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._unit.Temperature + + @property + def name(self): + """Return the name of the climate device.""" + return f"AC {self._ac_number}" + + @property + def fan_mode(self): + """Return fan mode of the AC this group belongs to.""" + return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._ac_number].AcFanSpeed] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsForAc(self._ac_number) + return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + is_off = self._unit.PowerState == "Off" + if is_off: + return HVAC_MODE_OFF + + return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode] + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number) + modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes] + modes.append(HVAC_MODE_OFF) + return modes + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode not in HA_STATE_TO_AT: + raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") + + if hvac_mode == HVAC_MODE_OFF: + return await self.async_turn_off() + await self._airtouch.SetCoolingModeForAc( + self._ac_number, HA_STATE_TO_AT[hvac_mode] + ) + # in case it isn't already, unless the HVAC mode was off, then the ac should be on + await self.async_turn_on() + self._unit = self._airtouch.GetAcs()[self._ac_number] + _LOGGER.debug("Setting operation mode of %s to %s", self._ac_number, hvac_mode) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + + _LOGGER.debug("Setting fan mode of %s to %s", self._ac_number, fan_mode) + await self._airtouch.SetFanSpeedForAc( + self._ac_number, HA_FAN_SPEED_TO_AT[fan_mode] + ) + self._unit = self._airtouch.GetAcs()[self._ac_number] + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + # in case ac is not on. Airtouch turns itself off if no groups are turned on + # (even if groups turned back on) + await self._airtouch.TurnAcOn(self._ac_number) + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + await self._airtouch.TurnAcOff(self._ac_number) + self.async_write_ha_state() + + +class AirtouchGroup(CoordinatorEntity, ClimateEntity): + """Representation of an AirTouch 4 group.""" + + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_temperature_unit = TEMP_CELSIUS + _attr_hvac_modes = AT_GROUP_MODES + + def __init__(self, coordinator, group_number, info): + """Initialize the climate device.""" + super().__init__(coordinator) + self._group_number = group_number + self._airtouch = coordinator.airtouch + self._info = info + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + return super()._handle_coordinator_update() + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Airtouch", + "model": "Airtouch 4", + } + + @property + def unique_id(self): + """Return unique ID for this device.""" + return self._group_number + + @property + def min_temp(self): + """Return Minimum Temperature for AC of this group.""" + return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint + + @property + def max_temp(self): + """Return Max Temperature for AC of this group.""" + return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint + + @property + def name(self): + """Return the name of the climate device.""" + return self._unit.GroupName + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._unit.Temperature + + @property + def target_temperature(self): + """Return the temperature we are trying to reach.""" + return self._unit.TargetSetpoint + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + # there are other power states that aren't 'on' but still count as on (eg. 'Turbo') + is_off = self._unit.PowerState == "Off" + if is_off: + return HVAC_MODE_OFF + + return HVAC_MODE_FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode not in HA_STATE_TO_AT: + raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") + + if hvac_mode == HVAC_MODE_OFF: + return await self.async_turn_off() + if self.hvac_mode == HVAC_MODE_OFF: + await self.async_turn_on() + self._unit = self._airtouch.GetGroups()[self._group_number] + _LOGGER.debug( + "Setting operation mode of %s to %s", self._group_number, hvac_mode + ) + self.async_write_ha_state() + + @property + def fan_mode(self): + """Return fan mode of the AC this group belongs to.""" + return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._unit.BelongsToAc].AcFanSpeed] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsByGroup( + self._group_number + ) + return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] + + async def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + + _LOGGER.debug("Setting temp of %s to %s", self._group_number, str(temp)) + self._unit = await self._airtouch.SetGroupToTemperature( + self._group_number, int(temp) + ) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + + _LOGGER.debug("Setting fan mode of %s to %s", self._group_number, fan_mode) + self._unit = await self._airtouch.SetFanSpeedByGroup( + self._group_number, HA_FAN_SPEED_TO_AT[fan_mode] + ) + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + await self._airtouch.TurnGroupOn(self._group_number) + + # in case ac is not on. Airtouch turns itself off if no groups are turned on + # (even if groups turned back on) + await self._airtouch.TurnAcOn( + self._airtouch.GetGroupByGroupNumber(self._group_number).BelongsToAc + ) + # this might cause the ac object to be wrong, so force the shared data + # store to update + await self.coordinator.async_request_refresh() + self.async_write_ha_state() + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + await self._airtouch.TurnGroupOff(self._group_number) + # this will cause the ac object to be wrong + # (ac turns off automatically if no groups are running) + # so force the shared data store to update + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/airtouch4/config_flow.py b/homeassistant/components/airtouch4/config_flow.py new file mode 100644 index 00000000000..e395c71349b --- /dev/null +++ b/homeassistant/components/airtouch4/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for AirTouch4.""" +from airtouch4pyapi import AirTouch, AirTouchStatus +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class AirtouchConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an Airtouch config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = {} + + host = user_input[CONF_HOST] + self._async_abort_entries_match({CONF_HOST: host}) + + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + airtouch_status = airtouch.Status + airtouch_has_groups = bool( + airtouch.Status == AirTouchStatus.OK and airtouch.GetGroups() + ) + + if airtouch_status != AirTouchStatus.OK: + errors["base"] = "cannot_connect" + elif not airtouch_has_groups: + errors["base"] = "no_units" + + if errors: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) diff --git a/homeassistant/components/airtouch4/const.py b/homeassistant/components/airtouch4/const.py new file mode 100644 index 00000000000..e110a6cee81 --- /dev/null +++ b/homeassistant/components/airtouch4/const.py @@ -0,0 +1,3 @@ +"""Constants for the AirTouch4 integration.""" + +DOMAIN = "airtouch4" diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json new file mode 100644 index 00000000000..8297081ae9d --- /dev/null +++ b/homeassistant/components/airtouch4/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "airtouch4", + "name": "AirTouch 4", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airtouch4", + "requirements": [ + "airtouch4pyapi==1.0.5" + ], + "codeowners": [ + "@LonePurpleWolf" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json new file mode 100644 index 00000000000..5259b20fb73 --- /dev/null +++ b/homeassistant/components/airtouch4/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "title": "Setup your AirTouch 4 connection details.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + } + } +} diff --git a/homeassistant/components/airtouch4/translations/ca.json b/homeassistant/components/airtouch4/translations/ca.json new file mode 100644 index 00000000000..083c4a0ba87 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "no_units": "No s'han trobat grups AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3" + }, + "title": "Configura els detalls de connexi\u00f3 d'AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/cs.json b/homeassistant/components/airtouch4/translations/cs.json new file mode 100644 index 00000000000..6fabc170b6e --- /dev/null +++ b/homeassistant/components/airtouch4/translations/cs.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "host": "Hostitel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/de.json b/homeassistant/components/airtouch4/translations/de.json new file mode 100644 index 00000000000..84f93d09962 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "no_units": "Es konnten keine AirTouch 4-Gruppen gefunden werden." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Richte deine AirTouch 4-Verbindungsdetails ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/en.json b/homeassistant/components/airtouch4/translations/en.json new file mode 100644 index 00000000000..0f86b787249 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Setup your AirTouch 4 connection details." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/et.json b/homeassistant/components/airtouch4/translations/et.json new file mode 100644 index 00000000000..2b42935b18e --- /dev/null +++ b/homeassistant/components/airtouch4/translations/et.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "no_units": "Ei leidnud \u00fchtegi AirTouch 4 gruppi." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "AirTouch 4 \u00fchenduse \u00fcksikasjade seadistamine." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/he.json b/homeassistant/components/airtouch4/translations/he.json new file mode 100644 index 00000000000..25fe66938d7 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/he.json @@ -0,0 +1,17 @@ +{ + "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" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/hu.json b/homeassistant/components/airtouch4/translations/hu.json new file mode 100644 index 00000000000..c5d54de31de --- /dev/null +++ b/homeassistant/components/airtouch4/translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen kapcsol\u00f3d\u00e1s", + "no_units": "Nem tal\u00e1lhat\u00f3 AirTouch 4 csoport." + }, + "step": { + "user": { + "data": { + "host": "Gazdag\u00e9p" + }, + "title": "\u00c1ll\u00edtsa be az AirTouch 4 csatlakoz\u00e1si adatait." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/it.json b/homeassistant/components/airtouch4/translations/it.json new file mode 100644 index 00000000000..f9a72a50e33 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "no_units": "Impossibile trovare alcun gruppo AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Imposta i dettagli della connessione AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/nl.json b/homeassistant/components/airtouch4/translations/nl.json new file mode 100644 index 00000000000..b0697ea04bf --- /dev/null +++ b/homeassistant/components/airtouch4/translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "no_units": "Kan geen AirTouch 4-groepen vinden." + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Stel uw AirTouch 4 verbindingsgegevens in." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/no.json b/homeassistant/components/airtouch4/translations/no.json new file mode 100644 index 00000000000..66bf4e3b915 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "no_units": "Kan ikke finne noen AirTouch 4 -grupper." + }, + "step": { + "user": { + "data": { + "host": "Vert" + }, + "title": "Konfigurer AirTouch 4 -tilkoblingsdetaljer." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/pl.json b/homeassistant/components/airtouch4/translations/pl.json new file mode 100644 index 00000000000..55f0b72b1a7 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "no_units": "Nie mo\u017cna znale\u017a\u0107 \u017cadnych grup AirTouch 4." + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "title": "Konfiguracja po\u0142\u0105czenia AirTouch 4." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/ru.json b/homeassistant/components/airtouch4/translations/ru.json new file mode 100644 index 00000000000..cbb7b10de79 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/ru.json @@ -0,0 +1,19 @@ +{ + "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.", + "no_units": "\u0413\u0440\u0443\u043f\u043f\u044b AirTouch 4 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "AirTouch 4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/translations/zh-Hant.json b/homeassistant/components/airtouch4/translations/zh-Hant.json new file mode 100644 index 00000000000..9ac310b531b --- /dev/null +++ b/homeassistant/components/airtouch4/translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "no_units": "\u627e\u4e0d\u5230\u4efb\u4f55 AirTouch 4 \u7fa4\u7d44\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "title": "\u8a2d\u5b9a AirTouch 4 \u9023\u7dda\u8cc7\u8a0a\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index c44e39b59e4..0419e43cd81 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -32,6 +32,7 @@ from homeassistant.helpers import ( config_validation as cv, entity_registry, ) +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -358,11 +359,14 @@ async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class AirVisualEntity(CoordinatorEntity): """Define a generic AirVisual entity.""" - def __init__(self, coordinator: DataUpdateCoordinator) -> None: + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: """Initialize.""" super().__init__(coordinator) self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self.entity_description = description async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 693742217e5..72f94875ea8 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,7 +1,7 @@ """Support for AirVisual air quality sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, @@ -14,10 +14,15 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_SHOW_ON_MAP, CONF_STATE, + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, PERCENTAGE, TEMP_CELSIUS, ) @@ -59,60 +64,84 @@ SENSOR_KIND_SENSOR_LIFE = "sensor_life" SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_VOC = "voc" -GEOGRAPHY_SENSORS = [ - (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), - (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), - (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), -] +GEOGRAPHY_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_KIND_LEVEL, + name="Air Pollution Level", + device_class=DEVICE_CLASS_POLLUTANT_LEVEL, + icon="mdi:gauge", + ), + SensorEntityDescription( + key=SENSOR_KIND_AQI, + name="Air Quality Index", + device_class=DEVICE_CLASS_AQI, + native_unit_of_measurement="AQI", + ), + SensorEntityDescription( + key=SENSOR_KIND_POLLUTANT, + name="Main Pollutant", + device_class=DEVICE_CLASS_POLLUTANT_LABEL, + icon="mdi:chemical-weapon", + ), +) GEOGRAPHY_SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} -NODE_PRO_SENSORS = [ - (SENSOR_KIND_AQI, "Air Quality Index", None, "mdi:chart-line", "AQI"), - (SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE), - ( - SENSOR_KIND_CO2, - "C02", - DEVICE_CLASS_CO2, - None, - CONCENTRATION_PARTS_PER_MILLION, +NODE_PRO_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_KIND_AQI, + name="Air Quality Index", + device_class=DEVICE_CLASS_AQI, + native_unit_of_measurement="AQI", ), - (SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, None, PERCENTAGE), - ( - SENSOR_KIND_PM_0_1, - "PM 0.1", - None, - "mdi:sprinkler", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SensorEntityDescription( + key=SENSOR_KIND_BATTERY_LEVEL, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, ), - ( - SENSOR_KIND_PM_1_0, - "PM 1.0", - None, - "mdi:sprinkler", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SensorEntityDescription( + key=SENSOR_KIND_CO2, + name="C02", + device_class=DEVICE_CLASS_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), - ( - SENSOR_KIND_PM_2_5, - "PM 2.5", - None, - "mdi:sprinkler", - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + SensorEntityDescription( + key=SENSOR_KIND_HUMIDITY, + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, ), - ( - SENSOR_KIND_TEMPERATURE, - "Temperature", - DEVICE_CLASS_TEMPERATURE, - None, - TEMP_CELSIUS, + SensorEntityDescription( + key=SENSOR_KIND_PM_0_1, + name="PM 0.1", + device_class=DEVICE_CLASS_PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), - ( - SENSOR_KIND_VOC, - "VOC", - None, - "mdi:sprinkler", - CONCENTRATION_PARTS_PER_MILLION, + 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, ), -] + 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, + ), + SensorEntityDescription( + key=SENSOR_KIND_TEMPERATURE, + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key=SENSOR_KIND_VOC, + name="VOC", + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), +) STATE_POLLUTANT_LABEL_CO = "co" STATE_POLLUTANT_LABEL_N2 = "n2" @@ -156,27 +185,19 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] sensors: list[AirVisualGeographySensor | AirVisualNodeProSensor] - if config_entry.data[CONF_INTEGRATION_TYPE] in [ + if config_entry.data[CONF_INTEGRATION_TYPE] in ( INTEGRATION_TYPE_GEOGRAPHY_COORDS, INTEGRATION_TYPE_GEOGRAPHY_NAME, - ]: + ): sensors = [ - AirVisualGeographySensor( - coordinator, - config_entry, - kind, - name, - icon, - unit, - locale, - ) + AirVisualGeographySensor(coordinator, config_entry, description, locale) for locale in GEOGRAPHY_SENSOR_LOCALES - for kind, name, icon, unit in GEOGRAPHY_SENSORS + for description in GEOGRAPHY_SENSOR_DESCRIPTIONS ] else: sensors = [ - AirVisualNodeProSensor(coordinator, kind, name, device_class, icon, unit) - for kind, name, device_class, icon, unit in NODE_PRO_SENSORS + AirVisualNodeProSensor(coordinator, description) + for description in NODE_PRO_SENSOR_DESCRIPTIONS ] async_add_entities(sensors, True) @@ -189,19 +210,12 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): self, coordinator: DataUpdateCoordinator, config_entry: ConfigEntry, - kind: str, - name: str, - icon: str, - unit: str | None, + description: SensorEntityDescription, locale: str, ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(coordinator, description) - if kind == SENSOR_KIND_LEVEL: - self._attr_device_class = DEVICE_CLASS_POLLUTANT_LEVEL - elif kind == SENSOR_KIND_POLLUTANT: - self._attr_device_class = DEVICE_CLASS_POLLUTANT_LABEL self._attr_extra_state_attributes.update( { ATTR_CITY: config_entry.data.get(CONF_CITY), @@ -209,12 +223,9 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): ATTR_COUNTRY: config_entry.data.get(CONF_COUNTRY), } ) - self._attr_icon = icon - self._attr_name = f"{GEOGRAPHY_SENSOR_LOCALES[locale]} {name}" - self._attr_unique_id = f"{config_entry.unique_id}_{locale}_{kind}" - self._attr_unit_of_measurement = unit + self._attr_name = f"{GEOGRAPHY_SENSOR_LOCALES[locale]} {description.name}" + self._attr_unique_id = f"{config_entry.unique_id}_{locale}_{description.key}" self._config_entry = config_entry - self._kind = kind self._locale = locale @property @@ -230,18 +241,18 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): except KeyError: return - if self._kind == SENSOR_KIND_LEVEL: + if self.entity_description.key == SENSOR_KIND_LEVEL: aqi = data[f"aqi{self._locale}"] - [(self._attr_state, self._attr_icon)] = [ + [(self._attr_native_value, self._attr_icon)] = [ (name, icon) for (floor, ceiling), (name, icon) in POLLUTANT_LEVELS.items() if floor <= aqi <= ceiling ] - elif self._kind == SENSOR_KIND_AQI: - self._attr_state = data[f"aqi{self._locale}"] - elif self._kind == SENSOR_KIND_POLLUTANT: + elif self.entity_description.key == SENSOR_KIND_AQI: + self._attr_native_value = data[f"aqi{self._locale}"] + elif self.entity_description.key == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] - self._attr_state = symbol + self._attr_native_value = symbol self._attr_extra_state_attributes.update( { ATTR_POLLUTANT_SYMBOL: symbol, @@ -281,25 +292,15 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to a Node/Pro unit.""" def __init__( - self, - coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, - unit: str, + self, coordinator: DataUpdateCoordinator, description: SensorEntityDescription ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(coordinator, description) - self._attr_device_class = device_class - self._attr_icon = icon self._attr_name = ( - f"{coordinator.data['settings']['node_name']} Node/Pro: {name}" + f"{coordinator.data['settings']['node_name']} Node/Pro: {description.name}" ) - self._attr_unique_id = f"{coordinator.data['serial_number']}_{kind}" - self._attr_unit_of_measurement = unit - self._kind = kind + self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}" @property def device_info(self) -> DeviceInfo: @@ -318,26 +319,32 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): @callback def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" - if self._kind == SENSOR_KIND_AQI: + if self.entity_description.key == SENSOR_KIND_AQI: if self.coordinator.data["settings"]["is_aqi_usa"]: - self._attr_state = self.coordinator.data["measurements"]["aqi_us"] + self._attr_native_value = self.coordinator.data["measurements"][ + "aqi_us" + ] else: - self._attr_state = self.coordinator.data["measurements"]["aqi_cn"] - elif self._kind == SENSOR_KIND_BATTERY_LEVEL: - self._attr_state = self.coordinator.data["status"]["battery"] - elif self._kind == SENSOR_KIND_CO2: - self._attr_state = self.coordinator.data["measurements"].get("co2") - elif self._kind == SENSOR_KIND_HUMIDITY: - self._attr_state = self.coordinator.data["measurements"].get("humidity") - elif self._kind == SENSOR_KIND_PM_0_1: - self._attr_state = self.coordinator.data["measurements"].get("pm0_1") - elif self._kind == SENSOR_KIND_PM_1_0: - self._attr_state = self.coordinator.data["measurements"].get("pm1_0") - elif self._kind == SENSOR_KIND_PM_2_5: - self._attr_state = self.coordinator.data["measurements"].get("pm2_5") - elif self._kind == SENSOR_KIND_TEMPERATURE: - self._attr_state = self.coordinator.data["measurements"].get( + self._attr_native_value = self.coordinator.data["measurements"][ + "aqi_cn" + ] + elif self.entity_description.key == SENSOR_KIND_BATTERY_LEVEL: + self._attr_native_value = self.coordinator.data["status"]["battery"] + elif self.entity_description.key == SENSOR_KIND_CO2: + self._attr_native_value = self.coordinator.data["measurements"].get("co2") + elif self.entity_description.key == SENSOR_KIND_HUMIDITY: + self._attr_native_value = self.coordinator.data["measurements"].get( + "humidity" + ) + elif self.entity_description.key == SENSOR_KIND_PM_0_1: + self._attr_native_value = self.coordinator.data["measurements"].get("pm0_1") + elif self.entity_description.key == SENSOR_KIND_PM_1_0: + self._attr_native_value = self.coordinator.data["measurements"].get("pm1_0") + elif self.entity_description.key == SENSOR_KIND_PM_2_5: + self._attr_native_value = self.coordinator.data["measurements"].get("pm2_5") + elif self.entity_description.key == SENSOR_KIND_TEMPERATURE: + self._attr_native_value = self.coordinator.data["measurements"].get( "temperature_C" ) - elif self._kind == SENSOR_KIND_VOC: - self._attr_state = self.coordinator.data["measurements"].get("voc") + elif self.entity_description.key == SENSOR_KIND_VOC: + self._attr_native_value = self.coordinator.data["measurements"].get("voc") diff --git a/homeassistant/components/airvisual/translations/es-419.json b/homeassistant/components/airvisual/translations/es-419.json index b0022391e62..6e26be959f9 100644 --- a/homeassistant/components/airvisual/translations/es-419.json +++ b/homeassistant/components/airvisual/translations/es-419.json @@ -13,6 +13,15 @@ "description": "Utilice la API en la nube de AirVisual para monitorear una latitud / longitud.", "title": "Configurar una geograf\u00eda" }, + "geography_by_name": { + "data": { + "city": "Ciudad", + "country": "Pa\u00eds", + "state": "estado" + }, + "description": "Utilice la API en la nube de AirVisual para monitorear una ciudad/estado/pa\u00eds.", + "title": "Configurar una geograf\u00eda" + }, "node_pro": { "data": { "ip_address": "Direcci\u00f3n IP/nombre de host de la unidad", @@ -21,6 +30,9 @@ "description": "Monitoree una unidad AirVisual personal. La contrase\u00f1a se puede recuperar de la interfaz de usuario de la unidad.", "title": "Configurar un AirVisual Node/Pro" }, + "reauth_confirm": { + "title": "Vuelva a autenticar AirVisual" + }, "user": { "description": "Monitoree la calidad del aire en una ubicaci\u00f3n geogr\u00e1fica.", "title": "Configurar AirVisual" diff --git a/homeassistant/components/airvisual/translations/hu.json b/homeassistant/components/airvisual/translations/hu.json index e7c47e93793..043a2402283 100644 --- a/homeassistant/components/airvisual/translations/hu.json +++ b/homeassistant/components/airvisual/translations/hu.json @@ -34,13 +34,29 @@ "data": { "ip_address": "Hoszt", "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.", + "title": "AirVisual Node/Pro konfigur\u00e1l\u00e1sa" }, "reauth_confirm": { "data": { "api_key": "API kulcs" }, "title": "Az AirVisual \u00fajb\u00f3li hiteles\u00edt\u00e9se" + }, + "user": { + "description": "V\u00e1lassza ki, hogy milyen t\u00edpus\u00fa AirVisual adatokat szeretne figyelni.", + "title": "Az AirVisual konfigur\u00e1l\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "A megfigyelt f\u00f6ldrajz megjelen\u00edt\u00e9se a t\u00e9rk\u00e9pen" + }, + "title": "Az AirVisual konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/airvisual/translations/sensor.cs.json b/homeassistant/components/airvisual/translations/sensor.cs.json new file mode 100644 index 00000000000..44c834c7df6 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.cs.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Oxid uhelnat\u00fd", + "n2": "Oxid dusi\u010dit\u00fd", + "o3": "Oz\u00f3n", + "p1": "PM10", + "p2": "PM2,5", + "s2": "Oxid si\u0159i\u010dit\u00fd" + }, + "airvisual__pollutant_level": { + "good": "Dobr\u00e9", + "hazardous": "Riskantn\u00ed", + "moderate": "M\u00edrn\u00e9", + "unhealthy": "Nezdrav\u00e9", + "unhealthy_sensitive": "Nezdrav\u00e9 pro citliv\u00e9 skupiny", + "very_unhealthy": "Velmi nezdrav\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.es-419.json b/homeassistant/components/airvisual/translations/sensor.es-419.json new file mode 100644 index 00000000000..7af0e1465aa --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.es-419.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f3xido de carbono", + "n2": "Dioxido de nitrogeno", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f3xido de azufre" + }, + "airvisual__pollutant_level": { + "good": "Bueno", + "hazardous": "Peligroso", + "moderate": "Moderado", + "unhealthy": "Insalubre", + "unhealthy_sensitive": "Insalubre para grupos sensibles", + "very_unhealthy": "Muy insalubre" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.es.json b/homeassistant/components/airvisual/translations/sensor.es.json new file mode 100644 index 00000000000..4a8a7cea1e3 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.es.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f3xido de carbono", + "n2": "Di\u00f3xido de nitr\u00f3geno", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f3xido de azufre" + }, + "airvisual__pollutant_level": { + "good": "Bien", + "hazardous": "Peligroso", + "moderate": "Moderado", + "unhealthy": "Insalubre", + "unhealthy_sensitive": "Incorrecto para grupos sensibles", + "very_unhealthy": "Muy poco saludable" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.hu.json b/homeassistant/components/airvisual/translations/sensor.hu.json new file mode 100644 index 00000000000..93fbb2ce510 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.hu.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Sz\u00e9n-monoxid", + "n2": "Nitrog\u00e9n-dioxid", + "o3": "\u00d3zon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "K\u00e9n-dioxid" + }, + "airvisual__pollutant_level": { + "good": "J\u00f3", + "hazardous": "Vesz\u00e9lyes", + "moderate": "M\u00e9rs\u00e9kelt", + "unhealthy": "Eg\u00e9szs\u00e9gtelen", + "unhealthy_sensitive": "Eg\u00e9szs\u00e9gtelen az \u00e9rz\u00e9keny csoportok sz\u00e1m\u00e1ra", + "very_unhealthy": "Nagyon eg\u00e9szs\u00e9gtelen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.no.json b/homeassistant/components/airvisual/translations/sensor.no.json index 86c95f8e8f2..cf142ad9f1a 100644 --- a/homeassistant/components/airvisual/translations/sensor.no.json +++ b/homeassistant/components/airvisual/translations/sensor.no.json @@ -1,8 +1,20 @@ { "state": { "airvisual__pollutant_label": { + "co": "Karbonmonoksid", + "n2": "Nitrogendioksid", + "o3": "Ozon", "p1": "PM10", - "p2": "PM2.5" + "p2": "PM2.5", + "s2": "Svoveldioksid" + }, + "airvisual__pollutant_level": { + "good": "Bra", + "hazardous": "Farlig", + "moderate": "Moderat", + "unhealthy": "Usunt", + "unhealthy_sensitive": "Usunt for sensitive grupper", + "very_unhealthy": "Veldig usunt" } } } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 9ab6e466b6c..695ec0ebb4a 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -1,7 +1,7 @@ """Provides device automations for Alarm control panel.""" from __future__ import annotations -from typing import Final +from typing import Any, Final import voluptuous as vol @@ -55,7 +55,7 @@ TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( async def async_get_triggers( hass: HomeAssistant, device_id: str -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: """List device triggers for Alarm control panel devices.""" registry = await entity_registry.async_get_registry(hass) triggers: list[dict[str, str]] = [] diff --git a/homeassistant/components/alarm_control_panel/translations/cs.json b/homeassistant/components/alarm_control_panel/translations/cs.json index 66786dfc0e2..7a831a2e2e6 100644 --- a/homeassistant/components/alarm_control_panel/translations/cs.json +++ b/homeassistant/components/alarm_control_panel/translations/cs.json @@ -4,6 +4,7 @@ "arm_away": "Aktivovat {entity_name} v re\u017eimu nep\u0159\u00edtomnost", "arm_home": "Aktivovat {entity_name} v re\u017eimu domov", "arm_night": "Aktivovat {entity_name} v no\u010dn\u00edm re\u017eimu", + "arm_vacation": "Aktivovat {entity_name} v re\u017eimu dovolen\u00e1", "disarm": "Odbezpe\u010dit {entity_name}", "trigger": "Spustit {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} je v re\u017eimu nep\u0159\u00edtomnost", "is_armed_home": "{entity_name} je v re\u017eimu domov", "is_armed_night": "{entity_name} je v no\u010dn\u00edm re\u017eimu", + "is_armed_vacation": "{entity_name} je v re\u017eimu dovolen\u00e1", "is_disarmed": "{entity_name} nen\u00ed zabezpe\u010den", "is_triggered": "{entity_name} je spu\u0161t\u011bn" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} v re\u017eimu nep\u0159\u00edtomnost", "armed_home": "{entity_name} v re\u017eimu domov", "armed_night": "{entity_name} v no\u010dn\u00edm re\u017eimu", + "armed_vacation": "{entity_name} v re\u017eimu dovolen\u00e1", "disarmed": "{entity_name} nezabezpe\u010den", "triggered": "{entity_name} spu\u0161t\u011bn" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Zabezpe\u010deno u\u017eivatelsk\u00fdm obejit\u00edm", "armed_home": "Re\u017eim domov", "armed_night": "No\u010dn\u00ed re\u017eim", + "armed_vacation": "V re\u017eimu dovolen\u00e1", "arming": "Zabezpe\u010dov\u00e1n\u00ed", "disarmed": "Nezabezpe\u010deno", "disarming": "Odbezpe\u010dov\u00e1n\u00ed", diff --git a/homeassistant/components/alarm_control_panel/translations/fr.json b/homeassistant/components/alarm_control_panel/translations/fr.json index 6d8ee9c08c3..bbcb26f7184 100644 --- a/homeassistant/components/alarm_control_panel/translations/fr.json +++ b/homeassistant/components/alarm_control_panel/translations/fr.json @@ -12,6 +12,7 @@ "is_armed_away": "{entity_name} est arm\u00e9", "is_armed_home": "{entity_name} est arm\u00e9 \u00e0 la maison", "is_armed_night": "{entity_name} est arm\u00e9 la nuit", + "is_armed_vacation": "{entity_name} est arm\u00e9 en mode vacances", "is_disarmed": "{entity_name} est d\u00e9sarm\u00e9", "is_triggered": "{entity_name} est d\u00e9clench\u00e9" }, diff --git a/homeassistant/components/alarm_control_panel/translations/hu.json b/homeassistant/components/alarm_control_panel/translations/hu.json index 961006938d9..5eba25a9ec2 100644 --- a/homeassistant/components/alarm_control_panel/translations/hu.json +++ b/homeassistant/components/alarm_control_panel/translations/hu.json @@ -9,7 +9,12 @@ "trigger": "{entity_name} riaszt\u00e1si esem\u00e9ny ind\u00edt\u00e1sa" }, "condition_type": { - "is_armed_vacation": "{entity_name} nyaral\u00e1s \u00e9les\u00edtve" + "is_armed_away": "{entity_name} \u00e9les\u00edtve van", + "is_armed_home": "{entity_name} \u00e9les\u00edtett otthoni m\u00f3dban", + "is_armed_night": "{entity_name} \u00e9les\u00edtett \u00e9jszaka m\u00f3dban", + "is_armed_vacation": "{entity_name} nyaral\u00e1s \u00e9les\u00edtve", + "is_disarmed": "{entity_name} hat\u00e1stalan\u00edtva", + "is_triggered": "{entity_name} aktiv\u00e1lva van" }, "trigger_type": { "armed_away": "{entity_name} t\u00e1voz\u00f3 m\u00f3dban lett \u00e9les\u00edtve", diff --git a/homeassistant/components/alarm_control_panel/translations/nl.json b/homeassistant/components/alarm_control_panel/translations/nl.json index 65b7cf1a4b8..0d81ed505f9 100644 --- a/homeassistant/components/alarm_control_panel/translations/nl.json +++ b/homeassistant/components/alarm_control_panel/translations/nl.json @@ -1,43 +1,43 @@ { "device_automation": { "action_type": { - "arm_away": "Inschakelen {entity_name} afwezig", - "arm_home": "Inschakelen {entity_name} thuis", - "arm_night": "Inschakelen {entity_name} nacht", + "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", - "disarm": "Uitschakelen {entity_name}", - "trigger": "Trigger {entity_name}" + "disarm": "Schakel {entity_name} uit", + "trigger": "Laat {entity_name} afgaan" }, "condition_type": { - "is_armed_away": "{entity_name} afwezig ingeschakeld", - "is_armed_home": "{entity_name} thuis ingeschakeld", - "is_armed_night": "{entity_name} nachtstand ingeschakeld", + "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_disarmed": "{entity_name} is uitgeschakeld", - "is_triggered": "{entity_name} wordt geactiveerd" + "is_triggered": "{entity_name} gaat af" }, "trigger_type": { - "armed_away": "{entity_name} afwezig ingeschakeld", - "armed_home": "{entity_name} thuis ingeschakeld", - "armed_night": "{entity_name} nachtstand ingeschakeld", + "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", "disarmed": "{entity_name} uitgeschakeld", - "triggered": "{entity_name} geactiveerd" + "triggered": "{entity_name} afgegaan" } }, "state": { "_": { "armed": "Ingeschakeld", - "armed_away": "Ingeschakeld afwezig", - "armed_custom_bypass": "Ingeschakeld met overbrugging(en)", - "armed_home": "Ingeschakeld thuis", - "armed_night": "Ingeschakeld nacht", + "armed_away": "Ingeschakeld voor vertrek", + "armed_custom_bypass": "Ingeschakeld met overbrugging", + "armed_home": "Ingeschakeld voor thuis", + "armed_night": "Ingeschakeld voor 's nachts", "armed_vacation": "Vakantie ingeschakeld", "arming": "Schakelt in", "disarmed": "Uitgeschakeld", "disarming": "Schakelt uit", "pending": "In wacht", - "triggered": "Geactiveerd" + "triggered": "Gaat af" } }, "title": "Alarm bedieningspaneel" diff --git a/homeassistant/components/alarm_control_panel/translations/no.json b/homeassistant/components/alarm_control_panel/translations/no.json index 465dd250086..ad8ed2c9c74 100644 --- a/homeassistant/components/alarm_control_panel/translations/no.json +++ b/homeassistant/components/alarm_control_panel/translations/no.json @@ -4,6 +4,7 @@ "arm_away": "Aktiver {entity_name} borte", "arm_home": "Aktiver {entity_name} hjemme", "arm_night": "Aktiver {entity_name} natt", + "arm_vacation": "{entity_name} ferie", "disarm": "Deaktiver {entity_name}", "trigger": "Utl\u00f8ser {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} er aktivert borte", "is_armed_home": "{entity_name} er aktivert hjemme", "is_armed_night": "{entity_name} er aktivert natt", + "is_armed_vacation": "{entity_name} er armert ferie", "is_disarmed": "{entity_name} er deaktivert", "is_triggered": "{entity_name} er utl\u00f8st" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} aktivert borte", "armed_home": "{entity_name} aktivert hjemme", "armed_night": "{entity_name} aktivert natt", + "armed_vacation": "{entity_name} armert ferie", "disarmed": "{entity_name} deaktivert", "triggered": "{entity_name} utl\u00f8st" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Armert tilpasset unntak", "armed_home": "Armert hjemme", "armed_night": "Armert natt", + "armed_vacation": "Armert ferie", "arming": "Armerer", "disarmed": "Avsl\u00e5tt", "disarming": "Disarmer", diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index 67b7ee4861a..16471010ee9 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -32,6 +32,6 @@ class AlarmDecoderSensor(SensorEntity): ) def _message_callback(self, message): - if self._attr_state != message.text: - self._attr_state = message.text + if self._attr_native_value != message.text: + self._attr_native_value = message.text self.schedule_update_ha_state() diff --git a/homeassistant/components/alarmdecoder/translations/es-419.json b/homeassistant/components/alarmdecoder/translations/es-419.json index 2152084ea56..c4cfbdf82ef 100644 --- a/homeassistant/components/alarmdecoder/translations/es-419.json +++ b/homeassistant/components/alarmdecoder/translations/es-419.json @@ -20,6 +20,10 @@ } }, "options": { + "error": { + "int": "El campo siguiente debe ser un n\u00famero entero.", + "loop_range": "El bucle de RF debe ser un n\u00famero entero entre 1 y 4." + }, "step": { "arm_settings": { "data": { @@ -30,6 +34,17 @@ "data": { "edit_select": "Editar" } + }, + "zone_details": { + "data": { + "zone_name": "Nombre de zona", + "zone_rfid": "Serie RF" + } + }, + "zone_select": { + "data": { + "zone_number": "N\u00famero de zona" + } } } } diff --git a/homeassistant/components/alarmdecoder/translations/hu.json b/homeassistant/components/alarmdecoder/translations/hu.json index 47db325f06c..ace9c7059ca 100644 --- a/homeassistant/components/alarmdecoder/translations/hu.json +++ b/homeassistant/components/alarmdecoder/translations/hu.json @@ -31,6 +31,7 @@ "error": { "int": "Az al\u00e1bbi mez\u0151nek eg\u00e9sz sz\u00e1mnak kell lennie.", "loop_range": "Az RF hurok eg\u00e9sz sz\u00e1m\u00e1nak 1 \u00e9s 4 k\u00f6z\u00f6tt kell lennie.", + "loop_rfid": "Az RF hurok nem haszn\u00e1lhat\u00f3 RF sorozat n\u00e9lk\u00fcl.", "relay_inclusive": "A rel\u00e9c\u00edm \u00e9s a rel\u00e9csatorna egym\u00e1st\u00f3l f\u00fcgg, \u00e9s egy\u00fctt kell felt\u00fcntetni." }, "step": { @@ -55,6 +56,7 @@ "zone_name": "Z\u00f3na neve", "zone_relayaddr": "Rel\u00e9 c\u00edm", "zone_relaychan": "Rel\u00e9 csatorna", + "zone_rfid": "RF soros", "zone_type": "Z\u00f3na t\u00edpusa" }, "description": "Adja meg a {zone_number} z\u00f3na adatait. {zone_number} z\u00f3na t\u00f6rl\u00e9s\u00e9hez hagyja \u00fcresen a Z\u00f3na neve elemet.", diff --git a/homeassistant/components/alarmdecoder/translations/nl.json b/homeassistant/components/alarmdecoder/translations/nl.json index 1ea9cb98b56..cbab651707a 100644 --- a/homeassistant/components/alarmdecoder/translations/nl.json +++ b/homeassistant/components/alarmdecoder/translations/nl.json @@ -32,7 +32,7 @@ "int": "Het onderstaande veld moet een geheel getal zijn.", "loop_range": "RF Lus moet een geheel getal zijn tussen 1 en 4.", "loop_rfid": "RF Lus kan niet worden gebruikt zonder RF Serieel.", - "relay_inclusive": "Het relais-adres en het relais-kanaal zijn codeafhankelijk en moeten samen worden opgenomen." + "relay_inclusive": "Het relaisadres en het relaiskanaal zijn onderling afhankelijk en moeten samen worden opgenomen." }, "step": { "arm_settings": { @@ -53,18 +53,18 @@ "zone_details": { "data": { "zone_loop": "RF Lus", - "zone_name": "Zone naam", - "zone_relayaddr": "Relais Adres", - "zone_relaychan": "Relais Kanaal", + "zone_name": "Zonenaam", + "zone_relayaddr": "Relaisadres", + "zone_relaychan": "Relaiskanaal", "zone_rfid": "RF Serieel", - "zone_type": "Zone Type" + "zone_type": "Zonetype" }, - "description": "Voer details in voor zone {zone_number}. Om zone {zone_number} te verwijderen, laat u Zone Name leeg.", + "description": "Voer details in voor zone {zone_number}. Om zone {zone_number} te verwijderen, laat u Zonenaam leeg.", "title": "Configureer AlarmDecoder" }, "zone_select": { "data": { - "zone_number": "Zone nummer" + "zone_number": "Zonenummer" }, "description": "Voer het zone nummer in dat u wilt toevoegen, bewerken of verwijderen.", "title": "Configureer AlarmDecoder" diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index db1fa990c54..fcd6ebf6ae2 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -99,7 +99,7 @@ class AlexaCapability: return False @staticmethod - def properties_non_controllable() -> bool: + def properties_non_controllable() -> bool | None: """Return True if non controllable.""" return None diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 29643bacc53..a6adc488f75 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -1,4 +1,6 @@ """Alexa related errors.""" +from __future__ import annotations + from homeassistant.exceptions import HomeAssistantError from .const import API_TEMP_UNITS @@ -22,8 +24,8 @@ class AlexaError(Exception): A handler can raise subclasses of this to return an error to the request. """ - namespace = None - error_type = None + namespace: str | None = None + error_type: str | None = None def __init__(self, error_message, payload=None): """Initialize an alexa error.""" diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py index 5d3b5a86942..6a5449e3d51 100644 --- a/homeassistant/components/almond/__init__.py +++ b/homeassistant/components/almond/__init__.py @@ -5,6 +5,7 @@ import asyncio from datetime import timedelta import logging import time +from typing import Optional, cast from aiohttp import ClientError, ClientSession import async_timeout @@ -166,7 +167,7 @@ async def _configure_almond_for_ha( _LOGGER.debug("Configuring Almond to connect to Home Assistant at %s", hass_url) store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) - data = await store.async_load() + data = cast(Optional[dict], await store.async_load()) if data is None: data = {} @@ -204,7 +205,7 @@ async def _configure_almond_for_ha( ) except (asyncio.TimeoutError, ClientError) as err: if isinstance(err, asyncio.TimeoutError): - msg = "Request timeout" + msg: str | ClientError = "Request timeout" else: msg = err _LOGGER.warning("Unable to configure Almond: %s", msg) diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index d6084569ff7..b7b56f93864 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -1,6 +1,9 @@ """Config flow to connect with Home Assistant.""" +from __future__ import annotations + import asyncio import logging +from typing import Any from aiohttp import ClientError import async_timeout @@ -9,6 +12,7 @@ import voluptuous as vol from yarl import URL from homeassistant import config_entries, core, data_entry_flow +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .const import DOMAIN as ALMOND_DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 @@ -64,7 +68,7 @@ class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): return result - async def async_oauth_create_entry(self, data: dict) -> dict: + async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow. Ok to override if you want to fetch extra info or even add another step. @@ -73,7 +77,7 @@ class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): data["host"] = self.host return self.async_create_entry(title=self.flow_impl.name, data=data) - async def async_step_import(self, user_input: dict = None) -> dict: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import data.""" # Only allow 1 instance. if self._async_current_entries(): diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 512de247ff2..583485ca703 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -112,7 +112,7 @@ class AlphaVantageSensor(SensorEntity): self._symbol = symbol[CONF_SYMBOL] self._attr_name = symbol.get(CONF_NAME, self._symbol) self._timeseries = timeseries - self._attr_unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) + self._attr_native_unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) self._attr_icon = ICONS.get(symbol.get(CONF_CURRENCY, "USD")) def update(self): @@ -120,7 +120,7 @@ class AlphaVantageSensor(SensorEntity): _LOGGER.debug("Requesting new data for symbol %s", self._symbol) all_values, _ = self._timeseries.get_intraday(self._symbol) values = next(iter(all_values.values())) - self._attr_state = values["1. open"] + self._attr_native_value = values["1. open"] self._attr_extra_state_attributes = ( { ATTR_ATTRIBUTION: ATTRIBUTION, @@ -148,7 +148,7 @@ class AlphaVantageForeignExchange(SensorEntity): else f"{self._to_currency}/{self._from_currency}" ) self._attr_icon = ICONS.get(self._from_currency, "USD") - self._attr_unit_of_measurement = self._to_currency + self._attr_native_unit_of_measurement = self._to_currency def update(self): """Get the latest data and updates the states.""" @@ -160,7 +160,7 @@ class AlphaVantageForeignExchange(SensorEntity): values, _ = self._foreign_exchange.get_currency_exchange_rate( from_currency=self._from_currency, to_currency=self._to_currency ) - self._attr_state = round(float(values["5. Exchange Rate"]), 4) + self._attr_native_value = round(float(values["5. Exchange Rate"]), 4) self._attr_extra_state_attributes = ( { ATTR_ATTRIBUTION: ATTRIBUTION, diff --git a/homeassistant/components/ambee/const.py b/homeassistant/components/ambee/const.py index d2570bea710..3fd57c17c63 100644 --- a/homeassistant/components/ambee/const.py +++ b/homeassistant/components/ambee/const.py @@ -39,38 +39,38 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { SensorEntityDescription( key="particulate_matter_2_5", name="Particulate Matter < 2.5 μm", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="particulate_matter_10", name="Particulate Matter < 10 μm", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="sulphur_dioxide", name="Sulphur Dioxide (SO2)", - unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="nitrogen_dioxide", name="Nitrogen Dioxide (NO2)", - unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="ozone", name="Ozone", - unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="carbon_monoxide", name="Carbon Monoxide (CO)", device_class=DEVICE_CLASS_CO, - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( @@ -85,21 +85,21 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Grass Pollen", icon="mdi:grass", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, ), SensorEntityDescription( key="tree", name="Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, ), SensorEntityDescription( key="weed", name="Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, ), SensorEntityDescription( key="grass_risk", @@ -124,7 +124,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Poaceae Grass Pollen", icon="mdi:grass", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -132,7 +132,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Alder Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -140,7 +140,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Birch Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -148,7 +148,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Cypress Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -156,7 +156,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Elm Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -164,7 +164,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Hazel Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -172,7 +172,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Oak Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -180,7 +180,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Pine Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -188,7 +188,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Plane Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -196,7 +196,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Poplar Tree Pollen", icon="mdi:tree", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -204,7 +204,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Chenopod Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -212,7 +212,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Mugwort Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -220,7 +220,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Nettle Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -228,7 +228,7 @@ SENSORS: dict[str, list[SensorEntityDescription]] = { name="Ragweed Weed Pollen", icon="mdi:sprout", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, ), ], diff --git a/homeassistant/components/ambee/sensor.py b/homeassistant/components/ambee/sensor.py index ecd04ffd204..bd125ac973e 100644 --- a/homeassistant/components/ambee/sensor.py +++ b/homeassistant/components/ambee/sensor.py @@ -66,7 +66,7 @@ class AmbeeSensorEntity(CoordinatorEntity, SensorEntity): } @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" value = getattr(self.coordinator.data, self.entity_description.key) if isinstance(value, str): diff --git a/homeassistant/components/ambee/translations/ca.json b/homeassistant/components/ambee/translations/ca.json index ab3c9cb949e..ac48eea1cd6 100644 --- a/homeassistant/components/ambee/translations/ca.json +++ b/homeassistant/components/ambee/translations/ca.json @@ -21,7 +21,7 @@ "longitude": "Longitud", "name": "Nom" }, - "description": "Configura Ambee per a integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 d'Ambee amb Home Assistant." } } } diff --git a/homeassistant/components/ambee/translations/es-419.json b/homeassistant/components/ambee/translations/es-419.json new file mode 100644 index 00000000000..dee7d514b48 --- /dev/null +++ b/homeassistant/components/ambee/translations/es-419.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "description": "Vuelva a autenticarse con su cuenta de Ambee." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.es-419.json b/homeassistant/components/ambee/translations/sensor.es-419.json new file mode 100644 index 00000000000..a676ca7aa5e --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.es-419.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Alto", + "low": "Bajo", + "moderate": "Moderado", + "very high": "Muy alto" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/hu.json b/homeassistant/components/ambiclimate/translations/hu.json index 04035f04cca..3898535c427 100644 --- a/homeassistant/components/ambiclimate/translations/hu.json +++ b/homeassistant/components/ambiclimate/translations/hu.json @@ -1,11 +1,22 @@ { "config": { "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." }, "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", + "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})", + "title": "Ambiclimate hiteles\u00edt\u00e9se" + } } } } \ No newline at end of file diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index d719f9b3728..68b8579f731 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -319,6 +319,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_API_KEY], config_entry.data[CONF_APP_KEY], session=session, + logger=LOGGER, ), ) hass.loop.create_task(ambient.ws_connect()) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 42b22d26a10..b95f4a8f13c 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.2.5"], + "requirements": ["aioambient==1.3.0"], "codeowners": ["@bachya"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index a606b401bc0..935a53e9384 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -61,7 +61,7 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): ambient, mac_address, station_name, sensor_type, sensor_name, device_class ) - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit @callback def update_from_latest_data(self) -> None: @@ -75,10 +75,10 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): ].get(TYPE_SOLARRADIATION) if w_m2_brightness_val is None: - self._attr_state = None + self._attr_native_value = None else: - self._attr_state = round(float(w_m2_brightness_val) / 0.0079) + self._attr_native_value = round(float(w_m2_brightness_val) / 0.0079) else: - self._attr_state = self._ambient.stations[self._mac_address][ + self._attr_native_value = self._ambient.stations[self._mac_address][ ATTR_LAST_DATA ].get(self._sensor_type) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 8d274f12044..26247816ac9 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,13 +1,18 @@ """Support for Amcrest IP cameras.""" +from __future__ import annotations + from contextlib import suppress -from datetime import timedelta +from dataclasses import dataclass +from datetime import datetime, timedelta import logging import threading +from typing import Any, Callable import aiohttp -from amcrest import AmcrestError, Http, LoginError +from amcrest import AmcrestError, ApiWrapper, LoginError import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.camera import DOMAIN as CAMERA @@ -27,14 +32,16 @@ from homeassistant.const import ( ENTITY_MATCH_NONE, HTTP_BASIC_AUTHENTICATION, ) +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.service import async_extract_entity_ids +from homeassistant.helpers.typing import ConfigType -from .binary_sensor import BINARY_POLLED_SENSORS, BINARY_SENSORS, check_binary_sensors +from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST from .const import ( CAMERAS, @@ -43,12 +50,11 @@ from .const import ( DATA_AMCREST, DEVICES, DOMAIN, - SENSOR_EVENT_CODE, SERVICE_EVENT, SERVICE_UPDATE, ) from .helpers import service_signal -from .sensor import SENSORS +from .sensor import SENSOR_KEYS _LOGGER = logging.getLogger(__name__) @@ -74,7 +80,7 @@ SCAN_INTERVAL = timedelta(seconds=10) AUTHENTICATION_LIST = {"basic": "basic"} -def _has_unique_names(devices): +def _has_unique_names(devices: list[dict[str, Any]]) -> list[dict[str, Any]]: names = [device[CONF_NAME] for device in devices] vol.Schema(vol.Unique())(names) return devices @@ -99,10 +105,13 @@ AMCREST_SCHEMA = vol.Schema( vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [vol.In(BINARY_SENSORS)], vol.Unique(), check_binary_sensors + cv.ensure_list, + [vol.In(BINARY_SENSOR_KEYS)], + vol.Unique(), + check_binary_sensors, ), vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSORS)], vol.Unique() + cv.ensure_list, [vol.In(SENSOR_KEYS)], vol.Unique() ), vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean, } @@ -114,10 +123,18 @@ CONFIG_SCHEMA = vol.Schema( ) -class AmcrestChecker(Http): - """amcrest.Http wrapper for catching errors.""" +class AmcrestChecker(ApiWrapper): + """amcrest.ApiWrapper wrapper for catching errors.""" - def __init__(self, hass, name, host, port, user, password): + def __init__( + self, + hass: HomeAssistant, + name: str, + host: str, + port: int, + user: str, + password: str, + ) -> None: """Initialize.""" self._hass = hass self._wrap_name = name @@ -126,7 +143,7 @@ class AmcrestChecker(Http): self._wrap_login_err = False self._wrap_event_flag = threading.Event() self._wrap_event_flag.set() - self._unsub_recheck = None + self._unsub_recheck: Callable[[], None] | None = None super().__init__( host, port, @@ -137,24 +154,24 @@ class AmcrestChecker(Http): ) @property - def available(self): + def available(self) -> bool: """Return if camera's API is responding.""" return self._wrap_errors <= MAX_ERRORS and not self._wrap_login_err @property - def available_flag(self): + def available_flag(self) -> threading.Event: """Return threading event flag that indicates if camera's API is responding.""" return self._wrap_event_flag - def _start_recovery(self): + def _start_recovery(self) -> None: self._wrap_event_flag.clear() dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) self._unsub_recheck = track_time_interval( self._hass, self._wrap_test_online, RECHECK_INTERVAL ) - def command(self, *args, **kwargs): - """amcrest.Http.command wrapper to catch errors.""" + def command(self, *args: Any, **kwargs: Any) -> Any: + """amcrest.ApiWrapper.command wrapper to catch errors.""" try: ret = super().command(*args, **kwargs) except LoginError as ex: @@ -182,6 +199,7 @@ class AmcrestChecker(Http): self._wrap_errors = 0 self._wrap_login_err = False if was_offline: + assert self._unsub_recheck is not None self._unsub_recheck() self._unsub_recheck = None _LOGGER.error("%s camera back online", self._wrap_name) @@ -189,15 +207,19 @@ class AmcrestChecker(Http): dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) return ret - def _wrap_test_online(self, now): + def _wrap_test_online(self, now: datetime) -> None: """Test if camera is back online.""" _LOGGER.debug("Testing if %s back online", self._wrap_name) with suppress(AmcrestError): self.current_time # pylint: disable=pointless-statement -def _monitor_events(hass, name, api, event_codes): - event_codes = set(event_codes) +def _monitor_events( + hass: HomeAssistant, + name: str, + api: AmcrestChecker, + event_codes: set[str], +) -> None: while True: api.available_flag.wait() try: @@ -218,7 +240,12 @@ def _monitor_events(hass, name, api, event_codes): ) -def _start_event_monitor(hass, name, api, event_codes): +def _start_event_monitor( + hass: HomeAssistant, + name: str, + api: AmcrestChecker, + event_codes: set[str], +) -> None: thread = threading.Thread( target=_monitor_events, name=f"Amcrest {name}", @@ -228,14 +255,14 @@ def _start_event_monitor(hass, name, api, event_codes): thread.start() -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Amcrest IP Camera component.""" hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) for device in config[DOMAIN]: - name = device[CONF_NAME] - username = device[CONF_USERNAME] - password = device[CONF_PASSWORD] + name: str = device[CONF_NAME] + username: str = device[CONF_USERNAME] + password: str = device[CONF_PASSWORD] api = AmcrestChecker( hass, name, device[CONF_HOST], device[CONF_PORT], username, password @@ -251,7 +278,9 @@ def setup(hass, config): # currently aiohttp only works with basic authentication # only valid for mjpeg streaming if device[CONF_AUTHENTICATION] == HTTP_BASIC_AUTHENTICATION: - authentication = aiohttp.BasicAuth(username, password) + authentication: aiohttp.BasicAuth | None = aiohttp.BasicAuth( + username, password + ) else: authentication = None @@ -266,7 +295,7 @@ def setup(hass, config): discovery.load_platform(hass, CAMERA, DOMAIN, {CONF_NAME: name}, config) - event_codes = [] + event_codes = set() if binary_sensors: discovery.load_platform( hass, @@ -275,11 +304,13 @@ def setup(hass, config): {CONF_NAME: name, CONF_BINARY_SENSORS: binary_sensors}, config, ) - event_codes = [ - BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] - for sensor_type in binary_sensors - if sensor_type not in BINARY_POLLED_SENSORS - ] + event_codes = { + sensor.event_code + for sensor in BINARY_SENSORS + if sensor.key in binary_sensors + and not sensor.should_poll + and sensor.event_code is not None + } _start_event_monitor(hass, name, api, event_codes) @@ -291,10 +322,10 @@ def setup(hass, config): if not hass.data[DATA_AMCREST][DEVICES]: return False - def have_permission(user, entity_id): + def have_permission(user: User | None, entity_id: str) -> bool: return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL) - async def async_extract_from_service(call): + async def async_extract_from_service(call: ServiceCall) -> list[str]: if call.context.user_id: user = await hass.auth.async_get_user(call.context.user_id) if user is None: @@ -325,7 +356,7 @@ def setup(hass, config): entity_ids.append(entity_id) return entity_ids - async def async_service_handler(call): + async def async_service_handler(call: ServiceCall) -> None: args = [] for arg in CAMERA_SERVICES[call.service][2]: args.append(call.data[arg]) @@ -338,22 +369,13 @@ def setup(hass, config): return True +@dataclass class AmcrestDevice: """Representation of a base Amcrest discovery device.""" - def __init__( - self, - api, - authentication, - ffmpeg_arguments, - stream_source, - resolution, - control_light, - ): - """Initialize the entity.""" - self.api = api - self.authentication = authentication - self.ffmpeg_arguments = ffmpeg_arguments - self.stream_source = stream_source - self.resolution = resolution - self.control_light = control_light + api: AmcrestChecker + authentication: aiohttp.BasicAuth | None + ffmpeg_arguments: list[str] + stream_source: str + resolution: int + control_light: bool diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index 0add382b81f..93e5b17d548 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -1,7 +1,11 @@ """Support for Amcrest IP camera binary sensors.""" +from __future__ import annotations + from contextlib import suppress +from dataclasses import dataclass from datetime import timedelta import logging +from typing import TYPE_CHECKING, Callable from amcrest import AmcrestError import voluptuous as vol @@ -11,70 +15,115 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_SOUND, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME -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 homeassistant.util import Throttle from .const import ( BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST, DEVICES, - SENSOR_DEVICE_CLASS, - SENSOR_EVENT_CODE, - SENSOR_NAME, SERVICE_EVENT, SERVICE_UPDATE, ) from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + + +@dataclass +class AmcrestSensorEntityDescription(BinarySensorEntityDescription): + """Describe Amcrest sensor entity.""" + + event_code: str | None = None + should_poll: bool = False + + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS) _ONLINE_SCAN_INTERVAL = timedelta(seconds=60 - BINARY_SENSOR_SCAN_INTERVAL_SECS) -BINARY_SENSOR_AUDIO_DETECTED = "audio_detected" -BINARY_SENSOR_AUDIO_DETECTED_POLLED = "audio_detected_polled" -BINARY_SENSOR_MOTION_DETECTED = "motion_detected" -BINARY_SENSOR_MOTION_DETECTED_POLLED = "motion_detected_polled" -BINARY_SENSOR_ONLINE = "online" -BINARY_SENSOR_CROSSLINE_DETECTED = "crossline_detected" -BINARY_SENSOR_CROSSLINE_DETECTED_POLLED = "crossline_detected_polled" -BINARY_POLLED_SENSORS = [ - BINARY_SENSOR_AUDIO_DETECTED_POLLED, - BINARY_SENSOR_MOTION_DETECTED_POLLED, - BINARY_SENSOR_ONLINE, -] -_AUDIO_DETECTED_PARAMS = ("Audio Detected", DEVICE_CLASS_SOUND, "AudioMutation") -_MOTION_DETECTED_PARAMS = ("Motion Detected", DEVICE_CLASS_MOTION, "VideoMotion") -_CROSSLINE_DETECTED_PARAMS = ( - "CrossLine Detected", - DEVICE_CLASS_MOTION, - "CrossLineDetection", +_AUDIO_DETECTED_KEY = "audio_detected" +_AUDIO_DETECTED_POLLED_KEY = "audio_detected_polled" +_AUDIO_DETECTED_NAME = "Audio Detected" +_AUDIO_DETECTED_EVENT_CODE = "AudioMutation" + +_CROSSLINE_DETECTED_KEY = "crossline_detected" +_CROSSLINE_DETECTED_POLLED_KEY = "crossline_detected_polled" +_CROSSLINE_DETECTED_NAME = "CrossLine Detected" +_CROSSLINE_DETECTED_EVENT_CODE = "CrossLineDetection" + +_MOTION_DETECTED_KEY = "motion_detected" +_MOTION_DETECTED_POLLED_KEY = "motion_detected_polled" +_MOTION_DETECTED_NAME = "Motion Detected" +_MOTION_DETECTED_EVENT_CODE = "VideoMotion" + +_ONLINE_KEY = "online" + +BINARY_SENSORS: tuple[AmcrestSensorEntityDescription, ...] = ( + AmcrestSensorEntityDescription( + key=_AUDIO_DETECTED_KEY, + name=_AUDIO_DETECTED_NAME, + device_class=DEVICE_CLASS_SOUND, + event_code=_AUDIO_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_AUDIO_DETECTED_POLLED_KEY, + name=_AUDIO_DETECTED_NAME, + device_class=DEVICE_CLASS_SOUND, + event_code=_AUDIO_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_CROSSLINE_DETECTED_KEY, + name=_CROSSLINE_DETECTED_NAME, + device_class=DEVICE_CLASS_MOTION, + event_code=_CROSSLINE_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_CROSSLINE_DETECTED_POLLED_KEY, + name=_CROSSLINE_DETECTED_NAME, + device_class=DEVICE_CLASS_MOTION, + event_code=_CROSSLINE_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_MOTION_DETECTED_KEY, + name=_MOTION_DETECTED_NAME, + device_class=DEVICE_CLASS_MOTION, + event_code=_MOTION_DETECTED_EVENT_CODE, + ), + AmcrestSensorEntityDescription( + key=_MOTION_DETECTED_POLLED_KEY, + name=_MOTION_DETECTED_NAME, + device_class=DEVICE_CLASS_MOTION, + event_code=_MOTION_DETECTED_EVENT_CODE, + should_poll=True, + ), + AmcrestSensorEntityDescription( + key=_ONLINE_KEY, + name="Online", + device_class=DEVICE_CLASS_CONNECTIVITY, + ), ) -BINARY_SENSORS = { - BINARY_SENSOR_AUDIO_DETECTED: _AUDIO_DETECTED_PARAMS, - BINARY_SENSOR_AUDIO_DETECTED_POLLED: _AUDIO_DETECTED_PARAMS, - BINARY_SENSOR_MOTION_DETECTED: _MOTION_DETECTED_PARAMS, - BINARY_SENSOR_MOTION_DETECTED_POLLED: _MOTION_DETECTED_PARAMS, - BINARY_SENSOR_CROSSLINE_DETECTED: _CROSSLINE_DETECTED_PARAMS, - BINARY_SENSOR_CROSSLINE_DETECTED_POLLED: _CROSSLINE_DETECTED_PARAMS, - BINARY_SENSOR_ONLINE: ("Online", DEVICE_CLASS_CONNECTIVITY, None), -} -BINARY_SENSORS = { - k: dict(zip((SENSOR_NAME, SENSOR_DEVICE_CLASS, SENSOR_EVENT_CODE), v)) - for k, v in BINARY_SENSORS.items() -} +BINARY_SENSOR_KEYS = [description.key for description in BINARY_SENSORS] _EXCLUSIVE_OPTIONS = [ - {BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSOR_MOTION_DETECTED_POLLED}, - {BINARY_SENSOR_CROSSLINE_DETECTED, BINARY_SENSOR_CROSSLINE_DETECTED_POLLED}, + {_AUDIO_DETECTED_KEY, _AUDIO_DETECTED_POLLED_KEY}, + {_MOTION_DETECTED_KEY, _MOTION_DETECTED_POLLED_KEY}, + {_CROSSLINE_DETECTED_KEY, _CROSSLINE_DETECTED_POLLED_KEY}, ] _UPDATE_MSG = "Updating %s binary sensor" -def check_binary_sensors(value): +def check_binary_sensors(value: list[str]) -> list[str]: """Validate binary sensor configurations.""" for exclusive_options in _EXCLUSIVE_OPTIONS: if len(set(value) & exclusive_options) > 1: @@ -84,17 +133,24 @@ def check_binary_sensors(value): return value -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 a binary sensor for an Amcrest IP Camera.""" if discovery_info is None: return name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] + binary_sensors = discovery_info[CONF_BINARY_SENSORS] async_add_entities( [ - AmcrestBinarySensor(name, device, sensor_type) - for sensor_type in discovery_info[CONF_BINARY_SENSORS] + AmcrestBinarySensor(name, device, entity_description) + for entity_description in BINARY_SENSORS + if entity_description.key in binary_sensors ], True, ) @@ -103,92 +159,81 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmcrestBinarySensor(BinarySensorEntity): """Binary sensor for Amcrest camera.""" - def __init__(self, name, device, sensor_type): + def __init__( + self, + name: str, + device: AmcrestDevice, + entity_description: AmcrestSensorEntityDescription, + ) -> None: """Initialize entity.""" - self._name = f"{name} {BINARY_SENSORS[sensor_type][SENSOR_NAME]}" self._signal_name = name self._api = device.api - self._sensor_type = sensor_type - self._state = None - self._device_class = BINARY_SENSORS[sensor_type][SENSOR_DEVICE_CLASS] - self._event_code = BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] - self._unsub_dispatcher = [] + self.entity_description: AmcrestSensorEntityDescription = entity_description + + self._attr_name = f"{name} {entity_description.name}" + self._attr_should_poll = entity_description.should_poll + self._unsub_dispatcher: list[Callable[[], None]] = [] @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return self._sensor_type in BINARY_POLLED_SENSORS - - @property - def name(self): - """Return entity name.""" - return self._name - - @property - def is_on(self): - """Return if entity is on.""" - return self._state - - @property - def device_class(self): - """Return device class.""" - return self._device_class - - @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" - return self._sensor_type == BINARY_SENSOR_ONLINE or self._api.available + return self.entity_description.key == _ONLINE_KEY or self._api.available - def update(self): + def update(self) -> None: """Update entity.""" - if self._sensor_type == BINARY_SENSOR_ONLINE: + if self.entity_description.key == _ONLINE_KEY: self._update_online() else: self._update_others() @Throttle(_ONLINE_SCAN_INTERVAL) - def _update_online(self): + def _update_online(self) -> None: if not (self._api.available or self.is_on): return - _LOGGER.debug(_UPDATE_MSG, self._name) + _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._state = self._api.available + self._attr_is_on = self._api.available - def _update_others(self): + def _update_others(self) -> None: if not self.available: return - _LOGGER.debug(_UPDATE_MSG, self._name) + _LOGGER.debug(_UPDATE_MSG, self.name) + + event_code = self.entity_description.event_code + if event_code is None: + _LOGGER.error("Binary sensor %s event code not set", self.name) + return try: - self._state = "channels" in self._api.event_channels_happened( - self._event_code - ) + 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) - async def async_on_demand_update(self): + async def async_on_demand_update(self) -> None: """Update state.""" - if self._sensor_type == BINARY_SENSOR_ONLINE: - _LOGGER.debug(_UPDATE_MSG, self._name) - self._state = self._api.available + if self.entity_description.key == _ONLINE_KEY: + _LOGGER.debug(_UPDATE_MSG, self.name) + self._attr_is_on = self._api.available self.async_write_ha_state() - return - self.async_schedule_update_ha_state(True) + else: + self.async_schedule_update_ha_state(True) @callback - def async_event_received(self, start): + def async_event_received(self, state: bool) -> None: """Update state from received event.""" - _LOGGER.debug(_UPDATE_MSG, self._name) - self._state = start + _LOGGER.debug(_UPDATE_MSG, self.name) + self._attr_is_on = state self.async_write_ha_state() - async def async_added_to_hass(self): + 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, @@ -196,16 +241,23 @@ class AmcrestBinarySensor(BinarySensorEntity): self.async_on_demand_update, ) ) - if self._event_code and self._sensor_type not in BINARY_POLLED_SENSORS: + if ( + self.entity_description.event_code + and not self.entity_description.should_poll + ): self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, - service_signal(SERVICE_EVENT, self._signal_name, self._event_code), + service_signal( + SERVICE_EVENT, + self._signal_name, + self.entity_description.event_code, + ), self.async_event_received, ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect from update signal.""" for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 92453d24144..772824864df 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,16 +1,21 @@ """Support for Amcrest IP cameras.""" +from __future__ import annotations + import asyncio from datetime import timedelta from functools import partial import logging +from typing import TYPE_CHECKING, Any, Callable +from aiohttp import web from amcrest import AmcrestError from haffmpeg.camera import CameraMjpeg import voluptuous as vol from homeassistant.components.camera import SUPPORT_ON_OFF, SUPPORT_STREAM, Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG +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.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, @@ -18,6 +23,8 @@ from homeassistant.helpers.aiohttp_client import ( ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( CAMERA_WEB_SESSION_TIMEOUT, @@ -30,6 +37,9 @@ from .const import ( ) from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=15) @@ -110,7 +120,12 @@ CAMERA_SERVICES = { _BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} -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 an Amcrest IP Camera.""" if discovery_info is None: return @@ -131,7 +146,7 @@ class AmcrestCommandFailed(Exception): class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" - def __init__(self, name, device, ffmpeg): + def __init__(self, name: str, device: AmcrestDevice, ffmpeg: FFmpegManager) -> None: """Initialize an Amcrest camera.""" super().__init__() self._name = name @@ -142,19 +157,19 @@ class AmcrestCam(Camera): self._resolution = device.resolution self._token = self._auth = device.authentication self._control_light = device.control_light - self._is_recording = False - self._motion_detection_enabled = None - self._brand = None - self._model = None - self._audio_enabled = None - self._motion_recording_enabled = None - self._color_bw = None - self._rtsp_url = None - self._snapshot_task = None - self._unsub_dispatcher = [] + self._is_recording: bool = False + self._motion_detection_enabled: bool = False + self._brand: str | None = None + self._model: str | None = None + self._audio_enabled: bool | None = None + self._motion_recording_enabled: bool | None = None + self._color_bw: str | None = None + self._rtsp_url: str | None = None + self._snapshot_task: asyncio.tasks.Task | None = None + self._unsub_dispatcher: list[Callable[[], None]] = [] self._update_succeeded = False - def _check_snapshot_ok(self): + def _check_snapshot_ok(self) -> None: available = self.available if not available or not self.is_on: _LOGGER.warning( @@ -164,7 +179,8 @@ class AmcrestCam(Camera): ) raise CannotSnapshot - async def _async_get_image(self): + 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. @@ -177,12 +193,15 @@ class AmcrestCam(Camera): ) except AmcrestError as error: log_update_error(_LOGGER, "get image from", self.name, "camera", error) - return None + return finally: self._snapshot_task = None - async def async_camera_image(self): + async def async_camera_image( + 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. @@ -203,8 +222,11 @@ class AmcrestCam(Camera): except CannotSnapshot: return None - async def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream( + 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) @@ -228,7 +250,7 @@ class AmcrestCam(Camera): return await async_aiohttp_proxy_web(self.hass, request, stream_coro) # streaming via ffmpeg - + assert self._rtsp_url is not None streaming_url = self._rtsp_url stream = CameraMjpeg(self._ffmpeg.binary) await stream.open_camera(streaming_url, extra_cmd=self._ffmpeg_arguments) @@ -255,12 +277,12 @@ class AmcrestCam(Camera): return True @property - def name(self): + def name(self) -> str: """Return the name of this camera.""" return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the Amcrest-specific camera state attributes.""" attr = {} if self._audio_enabled is not None: @@ -274,78 +296,80 @@ class AmcrestCam(Camera): return attr @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._api.available @property - def supported_features(self): + def supported_features(self) -> int: """Return supported features.""" return SUPPORT_ON_OFF | SUPPORT_STREAM # Camera property overrides @property - def is_recording(self): + def is_recording(self) -> bool: """Return true if the device is recording.""" return self._is_recording @property - def brand(self): + def brand(self) -> str | None: """Return the camera brand.""" return self._brand @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._motion_detection_enabled @property - def model(self): + def model(self) -> str | None: """Return the camera model.""" return self._model - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the source of the stream.""" return self._rtsp_url @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" return self.is_streaming # Other Entity method overrides - async def async_on_demand_update(self): + async def async_on_demand_update(self) -> None: """Update state.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to signals and add camera to list.""" - for service, params in CAMERA_SERVICES.items(): - self._unsub_dispatcher.append( - async_dispatcher_connect( - self.hass, - service_signal(service, self.entity_id), - getattr(self, params[1]), - ) + assert self.hass is not None + self._unsub_dispatcher.extend( + async_dispatcher_connect( + self.hass, + service_signal(service, self.entity_id), + getattr(self, callback_name), ) + for service, (_, callback_name, _) in CAMERA_SERVICES.items() + ) self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, - service_signal(SERVICE_UPDATE, self._name), + service_signal(SERVICE_UPDATE, self.name), self.async_on_demand_update, ) ) self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id) - async def async_will_remove_from_hass(self): + 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() - def update(self): + def update(self) -> None: """Update entity status.""" if not self.available or self._update_succeeded: if not self.available: @@ -361,10 +385,14 @@ class AmcrestCam(Camera): 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] 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) self.is_streaming = self._get_video() self._is_recording = self._get_recording() self._motion_detection_enabled = self._get_motion_detection() @@ -380,66 +408,77 @@ class AmcrestCam(Camera): # Other Camera method overrides - def turn_off(self): + def turn_off(self) -> None: """Turn off camera.""" self._enable_video(False) - def turn_on(self): + def turn_on(self) -> None: """Turn on camera.""" self._enable_video(True) - def enable_motion_detection(self): + def enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" self._enable_motion_detection(True) - def disable_motion_detection(self): + def disable_motion_detection(self) -> None: """Disable motion detection in camera.""" self._enable_motion_detection(False) # Additional Amcrest Camera service methods - async def async_enable_recording(self): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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, travel_time): + 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} @@ -463,11 +502,14 @@ class AmcrestCam(Camera): # Methods to send commands to Amcrest camera and handle errors - def _change_setting(self, value, attr, description, action="set"): + def _change_setting( + self, value: str | bool, description: str, attr: str | None = None + ) -> None: func = description.replace(" ", "_") description = f"camera {description} to {value}" - tries = 3 - while True: + action = "set" + max_tries = 3 + for tries in range(max_tries, 0, -1): try: getattr(self, f"_set_{func}")(value) new_value = getattr(self, f"_get_{func}")() @@ -485,109 +527,113 @@ class AmcrestCam(Camera): setattr(self, attr, new_value) self.schedule_update_ha_state() return - tries -= 1 - def _get_video(self): + def _get_video(self) -> bool: return self._api.video_enabled - def _set_video(self, enable): + def _set_video(self, enable: bool) -> None: self._api.video_enabled = enable - def _enable_video(self, enable): + def _enable_video(self, enable: bool) -> None: """Enable or disable camera video stream.""" # Given the way the camera's state is determined by # is_streaming and is_recording, we can't leave # recording on if video stream is being turned off. if self.is_recording and not enable: self._enable_recording(False) - self._change_setting(enable, "is_streaming", "video") + self._change_setting(enable, "video", "is_streaming") if self._control_light: self._change_light() - def _get_recording(self): + def _get_recording(self) -> bool: return self._api.record_mode == "Manual" - def _set_recording(self, enable): + def _set_recording(self, enable: bool) -> None: rec_mode = {"Automatic": 0, "Manual": 1} - self._api.record_mode = rec_mode["Manual" if enable else "Automatic"] + # The property has a str type, but setter has int type, which causes mypy confusion + self._api.record_mode = rec_mode["Manual" if enable else "Automatic"] # type: ignore[assignment] - def _enable_recording(self, enable): + def _enable_recording(self, enable: bool) -> None: """Turn recording on or off.""" # Given the way the camera's state is determined by # is_streaming and is_recording, we can't leave # video stream off if recording is being turned on. if not self.is_streaming and enable: self._enable_video(True) - self._change_setting(enable, "_is_recording", "recording") + self._change_setting(enable, "recording", "_is_recording") - def _get_motion_detection(self): + def _get_motion_detection(self) -> bool: return self._api.is_motion_detector_on() - def _set_motion_detection(self, enable): - self._api.motion_detection = str(enable).lower() + def _set_motion_detection(self, enable: bool) -> None: + # The property has a str type, but setter has bool type, which causes mypy confusion + self._api.motion_detection = enable # type: ignore[assignment] - def _enable_motion_detection(self, enable): + def _enable_motion_detection(self, enable: bool) -> None: """Enable or disable motion detection.""" - self._change_setting(enable, "_motion_detection_enabled", "motion detection") + self._change_setting(enable, "motion detection", "_motion_detection_enabled") - def _get_audio(self): + def _get_audio(self) -> bool: return self._api.audio_enabled - def _set_audio(self, enable): + def _set_audio(self, enable: bool) -> None: self._api.audio_enabled = enable - def _enable_audio(self, enable): + def _enable_audio(self, enable: bool) -> None: """Enable or disable audio stream.""" - self._change_setting(enable, "_audio_enabled", "audio") + self._change_setting(enable, "audio", "_audio_enabled") if self._control_light: self._change_light() - def _get_indicator_light(self): - return "true" in self._api.command( - "configManager.cgi?action=getConfig&name=LightGlobal" - ).content.decode("utf-8") + def _get_indicator_light(self) -> bool: + return ( + "true" + in self._api.command( + "configManager.cgi?action=getConfig&name=LightGlobal" + ).content.decode() + ) - def _set_indicator_light(self, enable): + def _set_indicator_light(self, enable: bool) -> None: self._api.command( f"configManager.cgi?action=setConfig&LightGlobal[0].Enable={str(enable).lower()}" ) - def _change_light(self): + def _change_light(self) -> None: """Enable or disable indicator light.""" self._change_setting( - self._audio_enabled or self.is_streaming, None, "indicator light" + self._audio_enabled or self.is_streaming, "indicator light" ) - def _get_motion_recording(self): + def _get_motion_recording(self) -> bool: return self._api.is_record_on_motion_detection() - def _set_motion_recording(self, enable): - self._api.motion_recording = str(enable).lower() + def _set_motion_recording(self, enable: bool) -> None: + self._api.motion_recording = enable - def _enable_motion_recording(self, enable): + def _enable_motion_recording(self, enable: bool) -> None: """Enable or disable motion recording.""" - self._change_setting(enable, "_motion_recording_enabled", "motion recording") + self._change_setting(enable, "motion recording", "_motion_recording_enabled") - def _goto_preset(self, preset): + def _goto_preset(self, preset: int) -> None: """Move camera position and zoom to preset.""" try: - self._api.go_to_preset(action="start", preset_point_number=preset) + self._api.go_to_preset(preset_point_number=preset) except AmcrestError as error: log_update_error( _LOGGER, "move", self.name, f"camera to preset {preset}", error ) - def _get_color_mode(self): + def _get_color_mode(self) -> str: return _CBW[self._api.day_night_color] - def _set_color_mode(self, cbw): + def _set_color_mode(self, cbw: str) -> None: self._api.day_night_color = _CBW.index(cbw) - def _set_color_bw(self, cbw): + def _set_color_bw(self, cbw: str) -> None: """Set camera color mode.""" - self._change_setting(cbw, "_color_bw", "color mode") + self._change_setting(cbw, "color mode", "_color_bw") - def _start_tour(self, start): + def _start_tour(self, start: bool) -> None: """Start camera tour.""" try: self._api.tour(start=start) diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py index ba7597d61af..89cde63a08a 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -13,7 +13,3 @@ SNAPSHOT_TIMEOUT = 20 SERVICE_EVENT = "event" SERVICE_UPDATE = "update" - -SENSOR_DEVICE_CLASS = "class" -SENSOR_EVENT_CODE = "code" -SENSOR_NAME = "name" diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index ef0ae2db15b..ff1a283769d 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -1,15 +1,24 @@ """Helpers for amcrest component.""" +from __future__ import annotations + import logging from .const import DOMAIN -def service_signal(service, *args): +def service_signal(service: str, *args: str) -> str: """Encode signal.""" return "_".join([DOMAIN, service, *args]) -def log_update_error(logger, action, name, entity_type, error, level=logging.ERROR): +def log_update_error( + logger: logging.Logger, + action: str, + name: str | None, + entity_type: str, + error: Exception, + level: int = logging.ERROR, +) -> None: """Log an update error.""" logger.log( level, diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 702e6a61487..acd93c4e2ed 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,8 +2,8 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.7.2"], + "requirements": ["amcrest==1.8.0"], "dependencies": ["ffmpeg"], - "codeowners": [], + "codeowners": ["@flacjacket"], "iot_class": "local_polling" } diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index a30de62494e..b916757f44a 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -1,40 +1,67 @@ """Support for Amcrest IP camera sensors.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import TYPE_CHECKING, Callable from amcrest import AmcrestError -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME, CONF_SENSORS, PERCENTAGE +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DATA_AMCREST, DEVICES, SENSOR_SCAN_INTERVAL_SECS, SERVICE_UPDATE from .helpers import log_update_error, service_signal +if TYPE_CHECKING: + from . import AmcrestDevice + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS) SENSOR_PTZ_PRESET = "ptz_preset" SENSOR_SDCARD = "sdcard" -# Sensor types are defined like: Name, units, icon -SENSORS = { - SENSOR_PTZ_PRESET: ["PTZ Preset", None, "mdi:camera-iris"], - SENSOR_SDCARD: ["SD Used", PERCENTAGE, "mdi:sd"], -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_PTZ_PRESET, + name="PTZ Preset", + icon="mdi:camera-iris", + ), + SensorEntityDescription( + key=SENSOR_SDCARD, + name="SD Used", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:sd", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -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 a sensor for an Amcrest IP Camera.""" if discovery_info is None: return name = discovery_info[CONF_NAME] device = hass.data[DATA_AMCREST][DEVICES][name] + sensors = discovery_info[CONF_SENSORS] async_add_entities( [ - AmcrestSensor(name, device, sensor_type) - for sensor_type in discovery_info[CONF_SENSORS] + AmcrestSensor(name, device, description) + for description in SENSOR_TYPES + if description.key in sensors ], True, ) @@ -43,93 +70,73 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AmcrestSensor(SensorEntity): """A sensor implementation for Amcrest IP camera.""" - def __init__(self, name, device, sensor_type): + def __init__( + self, name: str, device: AmcrestDevice, description: SensorEntityDescription + ) -> None: """Initialize a sensor for Amcrest camera.""" - self._name = f"{name} {SENSORS[sensor_type][0]}" + self.entity_description = description self._signal_name = name self._api = device.api - self._sensor_type = sensor_type - self._state = None - self._attrs = {} - self._unit_of_measurement = SENSORS[sensor_type][1] - self._icon = SENSORS[sensor_type][2] - self._unsub_dispatcher = None + self._unsub_dispatcher: Callable[[], None] | None = None + + self._attr_name = f"{name} {description.name}" + self._attr_extra_state_attributes = {} @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attrs - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit_of_measurement - - @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._api.available - def update(self): + def update(self) -> None: """Get the latest data and updates the state.""" if not self.available: return - _LOGGER.debug("Updating %s sensor", self._name) + _LOGGER.debug("Updating %s sensor", self.name) + sensor_type = self.entity_description.key try: - if self._sensor_type == SENSOR_PTZ_PRESET: - self._state = self._api.ptz_presets_count + if sensor_type == SENSOR_PTZ_PRESET: + self._attr_native_value = self._api.ptz_presets_count - elif self._sensor_type == SENSOR_SDCARD: + elif sensor_type == SENSOR_SDCARD: storage = self._api.storage_all try: - self._attrs[ + self._attr_extra_state_attributes[ "Total" ] = f"{storage['total'][0]:.2f} {storage['total'][1]}" except ValueError: - self._attrs[ + self._attr_extra_state_attributes[ "Total" ] = f"{storage['total'][0]} {storage['total'][1]}" try: - self._attrs[ + self._attr_extra_state_attributes[ "Used" ] = f"{storage['used'][0]:.2f} {storage['used'][1]}" except ValueError: - self._attrs["Used"] = f"{storage['used'][0]} {storage['used'][1]}" + self._attr_extra_state_attributes[ + "Used" + ] = f"{storage['used'][0]} {storage['used'][1]}" try: - self._state = f"{storage['used_percent']:.2f}" + self._attr_native_value = f"{storage['used_percent']:.2f}" except ValueError: - self._state = storage["used_percent"] + self._attr_native_value = storage["used_percent"] except AmcrestError as error: log_update_error(_LOGGER, "update", self.name, "sensor", error) - async def async_on_demand_update(self): + async def async_on_demand_update(self) -> None: """Update state.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + 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), self.async_on_demand_update, ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect from update signal.""" + assert self._unsub_dispatcher is not None self._unsub_dispatcher() diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index d41970a79de..f7bdb303eb7 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -5,12 +5,13 @@ from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.typing import ConfigType from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA -async def async_setup(hass: HomeAssistant, _): +async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: """Set up the analytics integration.""" analytics = Analytics(hass) @@ -35,8 +36,8 @@ async def async_setup(hass: HomeAssistant, _): @websocket_api.require_admin -@websocket_api.async_response @websocket_api.websocket_command({vol.Required("type"): "analytics"}) +@websocket_api.async_response async def websocket_analytics( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, @@ -51,13 +52,13 @@ async def websocket_analytics( @websocket_api.require_admin -@websocket_api.async_response @websocket_api.websocket_command( { vol.Required("type"): "analytics/preferences", vol.Required("preferences", default={}): PREFERENCE_SCHEMA, } ) +@websocket_api.async_response async def websocket_analytics_preferences( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 37aff988162..d7fa781945d 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -1,5 +1,6 @@ """Analytics helper class for the analytics integration.""" import asyncio +from typing import cast import uuid import aiohttp @@ -64,7 +65,11 @@ class Analytics: """Initialize the Analytics class.""" self.hass: HomeAssistant = hass self.session = async_get_clientsession(hass) - self._data = {ATTR_PREFERENCES: {}, ATTR_ONBOARDED: False, ATTR_UUID: None} + self._data: dict = { + ATTR_PREFERENCES: {}, + ATTR_ONBOARDED: False, + ATTR_UUID: None, + } self._store: Store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) @property @@ -103,7 +108,7 @@ class Analytics: async def load(self) -> None: """Load preferences.""" - stored = await self._store.async_load() + stored = cast(dict, await self._store.async_load()) if stored: self._data = stored diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index adedb297cd1..4bef3848617 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -50,12 +50,12 @@ class IPWebcamSensor(AndroidIPCamEntity, SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index d1e379435a0..00be4fa50c4 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,7 +3,7 @@ "name": "Android TV", "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ - "adb-shell[async]==0.3.4", + "adb-shell[async]==0.4.0", "androidtv[async]==0.0.60", "pure-python-adb[async]==0.3.0.dev0" ], diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 4d87ebc2592..8bc53bd86b7 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -461,7 +461,7 @@ class ADBDevice(MediaPlayerEntity): async def async_get_media_image(self): """Fetch current playing image.""" - if not self._screencap or self.state in [STATE_OFF, None] or not self.available: + if not self._screencap or self.state in (STATE_OFF, None) or not self.available: return None, None media_data = await self._adb_screencap() diff --git a/homeassistant/components/anthemav/manifest.json b/homeassistant/components/anthemav/manifest.json index 3e11675fa1f..078ecaae0da 100644 --- a/homeassistant/components/anthemav/manifest.json +++ b/homeassistant/components/anthemav/manifest.json @@ -2,7 +2,7 @@ "domain": "anthemav", "name": "Anthem A/V Receivers", "documentation": "https://www.home-assistant.io/integrations/anthemav", - "requirements": ["anthemav==1.1.10"], + "requirements": ["anthemav==1.2.0"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index bf1b8bf6db5..5937ff6a852 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -165,16 +165,16 @@ class APCUPSdSensor(SensorEntity): self.type = sensor_type self._attr_name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] self._attr_icon = SENSOR_TYPES[self.type][2] - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_device_class = SENSOR_TYPES[sensor_type][3] def update(self): """Get the latest status and use it to update our sensor state.""" if self.type.upper() not in self._data.status: - self._attr_state = None + self._attr_native_value = None else: - self._attr_state, inferred_unit = infer_unit( + self._attr_native_value, inferred_unit = infer_unit( self._data.status[self.type.upper()] ) - if not self._attr_unit_of_measurement: - self._attr_unit_of_measurement = inferred_unit + if not self._attr_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 0a11cf04651..a91d8540286 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -43,7 +43,6 @@ from homeassistant.helpers.system_info import async_get_system_info _LOGGER = logging.getLogger(__name__) ATTR_BASE_URL = "base_url" -ATTR_CURRENCY = "currency" ATTR_EXTERNAL_URL = "external_url" ATTR_INTERNAL_URL = "internal_url" ATTR_LOCATION_NAME = "location_name" @@ -196,7 +195,6 @@ class APIDiscoveryView(HomeAssistantView): # always needs authentication ATTR_REQUIRES_API_PASSWORD: True, ATTR_VERSION: __version__, - ATTR_CURRENCY: None, } with suppress(NoURLAvailableError): diff --git a/homeassistant/components/apple_tv/translations/es-419.json b/homeassistant/components/apple_tv/translations/es-419.json new file mode 100644 index 00000000000..75e6fb43ff2 --- /dev/null +++ b/homeassistant/components/apple_tv/translations/es-419.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "backoff": "El dispositivo no acepta solicitudes de emparejamiento en este momento (es posible que haya ingresado un c\u00f3digo PIN no v\u00e1lido demasiadas veces), vuelva a intentarlo m\u00e1s tarde.", + "device_did_not_pair": "No se intent\u00f3 finalizar el proceso de emparejamiento desde el dispositivo.", + "invalid_config": "La configuraci\u00f3n de este dispositivo est\u00e1 incompleta. Intente agregarlo nuevamente." + }, + "error": { + "no_usable_service": "Se encontr\u00f3 un dispositivo, pero no se pudo identificar ninguna forma de establecer una conexi\u00f3n con \u00e9l. Si sigue viendo este mensaje, intente especificar su direcci\u00f3n IP o reinicie su Apple TV." + }, + "step": { + "confirm": { + "description": "Est\u00e1 a punto de agregar el Apple TV llamado `{name} ` a Home Assistant. \n\n** Para completar el proceso, es posible que deba ingresar varios c\u00f3digos PIN. ** \n\nTenga en cuenta que *no* podr\u00e1 apagar su Apple TV con esta integraci\u00f3n. \u00a1Solo se apagar\u00e1 el reproductor multimedia en Home Assistant!", + "title": "Confirma la adici\u00f3n de Apple TV" + }, + "pair_no_pin": { + "description": "El emparejamiento es necesario para el servicio `{protocol}`. Ingresa el PIN {pin} en tu Apple TV para continuar.", + "title": "Emparejamiento" + }, + "pair_with_pin": { + "description": "El emparejamiento es necesario para el `{protocol}`. Ingrese el c\u00f3digo PIN que se muestra en la pantalla. Se omitir\u00e1n los ceros iniciales, es decir, introduzca 123 si el c\u00f3digo que se muestra es 0123.", + "title": "Emparejamiento" + }, + "reconfigure": { + "description": "Este Apple TV est\u00e1 experimentando algunas dificultades de conexi\u00f3n y debe reconfigurarse.", + "title": "Reconfiguraci\u00f3n del dispositivo" + }, + "service_problem": { + "description": "Ocurri\u00f3 un problema al emparejar el protocolo \" {protocol} \". Ser\u00e1 ignorado.", + "title": "No se pudo agregar el servicio" + }, + "user": { + "data": { + "device_input": "Dispositivo" + }, + "description": "Comience ingresando el nombre del dispositivo (por ejemplo, cocina o dormitorio) o la direcci\u00f3n IP del Apple TV que desea agregar. Si se encontraron dispositivos autom\u00e1ticamente en su red, se muestran a continuaci\u00f3n. \n\nSi no puede ver su dispositivo o experimenta alg\u00fan problema, intente especificar la direcci\u00f3n IP del dispositivo. \n\n{devices}", + "title": "Configurar un nuevo Apple TV" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "start_off": "No encienda el dispositivo al iniciar Home Assistant" + }, + "description": "Configurar los ajustes generales del dispositivo" + } + } + }, + "title": "Apple TV" +} \ No newline at end of file diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 3e87d38ff69..bf24f2fdac5 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.3"], + "requirements": ["apprise==0.9.4"], "codeowners": ["@caronc"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index fff73cf00fa..394f8844adb 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -92,11 +92,11 @@ class AquaLogicSensor(SensorEntity): panel = self._processor.panel if panel is not None: if panel.is_metric: - self._attr_unit_of_measurement = SENSOR_TYPES[self._type][1][0] + self._attr_native_unit_of_measurement = SENSOR_TYPES[self._type][1][0] else: - self._attr_unit_of_measurement = SENSOR_TYPES[self._type][1][1] + self._attr_native_unit_of_measurement = SENSOR_TYPES[self._type][1][1] - self._attr_state = getattr(panel, self._type) + self._attr_native_value = getattr(panel, self._type) self.async_write_ha_state() else: - self._attr_unit_of_measurement = None + self._attr_native_unit_of_measurement = None diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index c1df4fc0587..d28de3b92aa 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -36,7 +36,7 @@ async def _await_cancel(task): await task -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" hass.data[DOMAIN_DATA_ENTRIES] = {} hass.data[DOMAIN_DATA_TASKS] = {} diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index 383f28d7a20..7bf7a06d851 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Arcam FMJ Receiver control.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,7 +30,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Arcam FMJ Receiver control devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] diff --git a/homeassistant/components/arcam_fmj/translations/es-419.json b/homeassistant/components/arcam_fmj/translations/es-419.json new file mode 100644 index 00000000000..a69b353354b --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "confirm": { + "description": "\u00bfDesea agregar Arcam FMJ en `{host}` a Home Assistant?" + }, + "user": { + "description": "Ingrese el nombre de host o la direcci\u00f3n IP del dispositivo." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/he.json b/homeassistant/components/arcam_fmj/translations/he.json index c07b9af0c67..447d79eed28 100644 --- a/homeassistant/components/arcam_fmj/translations/he.json +++ b/homeassistant/components/arcam_fmj/translations/he.json @@ -5,12 +5,6 @@ "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, - "error": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "flow_title": "{host}", "step": { "confirm": { diff --git a/homeassistant/components/arcam_fmj/translations/hu.json b/homeassistant/components/arcam_fmj/translations/hu.json index e1784c4ad66..9539ad39bed 100644 --- a/homeassistant/components/arcam_fmj/translations/hu.json +++ b/homeassistant/components/arcam_fmj/translations/hu.json @@ -5,14 +5,27 @@ "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, + "error": { + "one": "\u00dcres", + "other": "\u00dcres" + }, "flow_title": "{host}", "step": { + "confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni az Arcam FMJ \"{host}\" eszk\u00f6zt a HomeAssistanthoz?" + }, "user": { "data": { "host": "Hoszt", "port": "Port" - } + }, + "description": "K\u00e9rj\u00fck, adja meg az eszk\u00f6z gazdag\u00e9pnev\u00e9t vagy IP-c\u00edm\u00e9t." } } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} bekapcsol\u00e1s\u00e1t k\u00e9rt\u00e9k" + } } } \ No newline at end of file diff --git a/homeassistant/components/arduino/sensor.py b/homeassistant/components/arduino/sensor.py index fa624a7d167..0853fb5537d 100644 --- a/homeassistant/components/arduino/sensor.py +++ b/homeassistant/components/arduino/sensor.py @@ -42,4 +42,4 @@ class ArduinoSensor(SensorEntity): def update(self): """Get the latest value from the pin.""" - self._attr_state = self._board.get_analog_inputs()[self._pin][1] + self._attr_native_value = self._board.get_analog_inputs()[self._pin][1] diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 7129b989f47..addd666e30e 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -141,7 +141,7 @@ class ArestSensor(SensorEntity): self.arest = arest self._attr_name = f"{location.title()} {name.title()}" self._variable = variable - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._renderer = renderer if pin is not None: @@ -155,9 +155,9 @@ class ArestSensor(SensorEntity): self._attr_available = self.arest.available values = self.arest.data if "error" in values: - self._attr_state = values["error"] + self._attr_native_value = values["error"] else: - self._attr_state = self._renderer( + self._attr_native_value = self._renderer( values.get("value", values.get(self._variable, None)) ) diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index d20eb7a5f8d..ecbf24c23ca 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -88,6 +88,7 @@ class ArestSwitchBase(SwitchEntity): self._resource = resource self._attr_name = f"{location.title()} {name.title()}" self._attr_available = True + self._attr_is_on = False class ArestSwitchFunction(ArestSwitchBase): diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index 87c6216e56d..6b14f0cee0c 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -1,4 +1,6 @@ """Support for Netgear Arlo IP cameras.""" +from __future__ import annotations + import logging from haffmpeg.camera import CameraMjpeg @@ -62,7 +64,9 @@ class ArloCam(Camera): self._last_refresh = None self.attrs = {} - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return self._camera.last_image_from_cache diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index c794bf1ef5e..57c897cfd59 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -1,13 +1,21 @@ """Sensor support for Netgear Arlo IP cameras.""" +from __future__ import annotations + +from dataclasses import replace 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 ( ATTR_ATTRIBUTION, CONCENTRATION_PARTS_PER_MILLION, CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, @@ -22,22 +30,59 @@ from . import ATTRIBUTION, DATA_ARLO, DEFAULT_BRAND, SIGNAL_UPDATE_ARLO _LOGGER = logging.getLogger(__name__) -# sensor_type [ description, unit, icon ] -SENSOR_TYPES = { - "last_capture": ["Last", None, "run-fast"], - "total_cameras": ["Arlo Cameras", None, "video"], - "captured_today": ["Captured Today", None, "file-video"], - "battery_level": ["Battery Level", PERCENTAGE, "battery-50"], - "signal_strength": ["Signal Strength", None, "signal"], - "temperature": ["Temperature", TEMP_CELSIUS, "thermometer"], - "humidity": ["Humidity", PERCENTAGE, "water-percent"], - "air_quality": ["Air Quality", CONCENTRATION_PARTS_PER_MILLION, "biohazard"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="last_capture", + name="Last", + icon="mdi:run-fast", + ), + SensorEntityDescription( + key="total_cameras", + name="Arlo Cameras", + icon="mdi:video", + ), + SensorEntityDescription( + key="captured_today", + name="Captured Today", + icon="mdi:file-video", + ), + SensorEntityDescription( + key="battery_level", + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), + SensorEntityDescription( + key="signal_strength", + name="Signal Strength", + icon="mdi:signal", + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key="air_quality", + name="Air Quality", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:biohazard", + ), +) + +SENSOR_KEYS = [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)] ) } ) @@ -50,24 +95,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return sensors = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - if sensor_type == "total_cameras": - sensors.append(ArloSensor(SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) + for sensor_original in SENSOR_TYPES: + if sensor_original.key not in config[CONF_MONITORED_CONDITIONS]: + continue + sensor_entry = replace(sensor_original) + if sensor_entry.key == "total_cameras": + sensors.append(ArloSensor(arlo, sensor_entry)) else: for camera in arlo.cameras: - if sensor_type in ("temperature", "humidity", "air_quality"): + if sensor_entry.key in ("temperature", "humidity", "air_quality"): continue - name = f"{SENSOR_TYPES[sensor_type][0]} {camera.name}" - sensors.append(ArloSensor(name, camera, sensor_type)) + sensor_entry.name = f"{sensor_entry.name} {camera.name}" + sensors.append(ArloSensor(camera, sensor_entry)) for base_station in arlo.base_stations: if ( - sensor_type in ("temperature", "humidity", "air_quality") + sensor_entry.key in ("temperature", "humidity", "air_quality") and base_station.model_id == "ABC1000" ): - name = f"{SENSOR_TYPES[sensor_type][0]} {base_station.name}" - sensors.append(ArloSensor(name, base_station, sensor_type)) + sensor_entry.name = f"{sensor_entry.name} {base_station.name}" + sensors.append(ArloSensor(base_station, sensor_entry)) add_entities(sensors, True) @@ -75,19 +123,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ArloSensor(SensorEntity): """An implementation of a Netgear Arlo IP sensor.""" - def __init__(self, name, device, sensor_type): + def __init__(self, device, sensor_entry): """Initialize an Arlo sensor.""" - _LOGGER.debug("ArloSensor created for %s", name) - self._name = name + self.entity_description = sensor_entry self._data = device - self._sensor_type = sensor_type self._state = None - self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" - - @property - def name(self): - """Return the name of this camera.""" - return self._name async def async_added_to_hass(self): """Register callbacks.""" @@ -103,43 +143,29 @@ class ArloSensor(SensorEntity): self.async_schedule_update_ha_state(True) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery_level" and self._state is not None: + if self.entity_description.key == "battery_level" and self._state is not None: return icon_for_battery_level( battery_level=int(self._state), charging=False ) - return self._icon - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return SENSOR_TYPES.get(self._sensor_type)[1] - - @property - def device_class(self): - """Return the device class of the sensor.""" - if self._sensor_type == "temperature": - return DEVICE_CLASS_TEMPERATURE - if self._sensor_type == "humidity": - return DEVICE_CLASS_HUMIDITY - return None + return self.entity_description.icon def update(self): """Get the latest data and updates the state.""" _LOGGER.debug("Updating Arlo sensor %s", self.name) - if self._sensor_type == "total_cameras": + if self.entity_description.key == "total_cameras": self._state = len(self._data.cameras) - elif self._sensor_type == "captured_today": + elif self.entity_description.key == "captured_today": self._state = len(self._data.captured_today) - elif self._sensor_type == "last_capture": + elif self.entity_description.key == "last_capture": try: video = self._data.last_video self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") @@ -151,31 +177,31 @@ class ArloSensor(SensorEntity): _LOGGER.debug(error_msg) self._state = None - elif self._sensor_type == "battery_level": + elif self.entity_description.key == "battery_level": try: self._state = self._data.battery_level except TypeError: self._state = None - elif self._sensor_type == "signal_strength": + elif self.entity_description.key == "signal_strength": try: self._state = self._data.signal_strength except TypeError: self._state = None - elif self._sensor_type == "temperature": + elif self.entity_description.key == "temperature": try: self._state = self._data.ambient_temperature except TypeError: self._state = None - elif self._sensor_type == "humidity": + elif self.entity_description.key == "humidity": try: self._state = self._data.ambient_humidity except TypeError: self._state = None - elif self._sensor_type == "air_quality": + elif self.entity_description.key == "air_quality": try: self._state = self._data.ambient_air_quality except TypeError: @@ -189,7 +215,7 @@ class ArloSensor(SensorEntity): attrs[ATTR_ATTRIBUTION] = ATTRIBUTION attrs["brand"] = DEFAULT_BRAND - if self._sensor_type != "total_cameras": + if self.entity_description.key != "total_cameras": attrs["model"] = self._data.model_id return attrs diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 2300319f9a4..321be5035cd 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -138,7 +138,7 @@ class ArwnSensor(SensorEntity): # This mqtt topic for the sensor which is its uid self._attr_unique_id = topic self._state_key = state_key - self._attr_unit_of_measurement = units + self._attr_native_unit_of_measurement = units self._attr_icon = icon self._attr_device_class = device_class @@ -147,5 +147,5 @@ class ArwnSensor(SensorEntity): ev = {} ev.update(event) self._attr_extra_state_attributes = ev - self._attr_state = ev.get(self._state_key, None) + self._attr_native_value = ev.get(self._state_key, None) self.async_write_ha_state() diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 0ffa674e054..c48ea4d57fe 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -49,12 +49,7 @@ _LOGGER = logging.getLogger(__name__) def _is_file(value) -> bool: """Validate that the value is an existing file.""" file_in = os.path.expanduser(str(value)) - - if not os.path.isfile(file_in): - return False - if not os.access(file_in, os.R_OK): - return False - return True + return os.path.isfile(file_in) and os.access(file_in, os.R_OK) def _get_ip(host): diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 0b5d81e3de9..d5d3d9026b5 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -19,7 +19,7 @@ async def async_setup_entry( ) -> None: """Set up device tracker for AsusWrt component.""" router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] - tracked = set() + tracked: set = set() @callback def update_router(): @@ -60,6 +60,12 @@ class AsusWrtDevice(ScannerEntity): self._device = device self._attr_unique_id = device.mac self._attr_name = device.name or DEFAULT_DEVICE_NAME + self._attr_device_info = { + "connections": {(CONNECTION_NETWORK_MAC, device.mac)}, + "default_model": "ASUSWRT Tracked device", + } + if device.name: + self._attr_device_info["default_name"] = device.name @property def is_connected(self): @@ -90,11 +96,6 @@ class AsusWrtDevice(ScannerEntity): def async_on_demand_update(self): """Update state.""" self._device = self._router.devices[self._device.mac] - self._attr_device_info = { - "connections": {(CONNECTION_NETWORK_MAC, self._device.mac)}, - } - if self._device.name: - self._attr_device_info["default_name"] = self._device.name self._attr_extra_state_attributes = {} if self._device.last_activity: self._attr_extra_state_attributes[ diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 9d1bcb35c9e..9acea7ba762 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging -from typing import Any +from typing import Any, Callable from aioasuswrt.asuswrt import AsusWrt @@ -209,16 +209,16 @@ class AsusWrtRouter: self._protocol = entry.data[CONF_PROTOCOL] self._host = entry.data[CONF_HOST] self._model = "Asus Router" - self._sw_v = None + self._sw_v: str | None = None self._devices: dict[str, Any] = {} self._connected_devices = 0 self._connect_error = False - self._sensors_data_handler: AsusWrtSensorDataHandler = None + self._sensors_data_handler: AsusWrtSensorDataHandler | None = None self._sensors_coordinator: dict[str, Any] = {} - self._on_close = [] + self._on_close: list[Callable] = [] self._options = { CONF_DNSMASQ: DEFAULT_DNSMASQ, @@ -229,7 +229,7 @@ class AsusWrtRouter: async def setup(self) -> None: """Set up a AsusWrt router.""" - self._api = get_api(self._entry.data, self._options) + self._api = get_api(dict(self._entry.data), self._options) try: await self._api.connection.async_connect() diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 679ae832394..5392b419bca 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -1,15 +1,19 @@ """Asuswrt status sensors.""" from __future__ import annotations +from dataclasses import dataclass import logging -from numbers import Number -from typing import Any +from numbers import Real -from homeassistant.components.sensor import 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 DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -25,62 +29,90 @@ from .const import ( ) from .router import KEY_COORDINATOR, KEY_SENSORS, AsusWrtRouter + +@dataclass +class AsusWrtSensorEntityDescription(SensorEntityDescription): + """A class that describes AsusWrt sensor entities.""" + + factor: int | None = None + precision: int = 2 + + DEFAULT_PREFIX = "Asuswrt" - -SENSOR_DEVICE_CLASS = "device_class" -SENSOR_ICON = "icon" -SENSOR_NAME = "name" -SENSOR_UNIT = "unit" -SENSOR_FACTOR = "factor" -SENSOR_DEFAULT_ENABLED = "default_enabled" - UNIT_DEVICES = "Devices" -CONNECTION_SENSORS = { - SENSORS_CONNECTED_DEVICE[0]: { - SENSOR_NAME: "Devices Connected", - SENSOR_UNIT: UNIT_DEVICES, - SENSOR_FACTOR: 0, - SENSOR_ICON: "mdi:router-network", - SENSOR_DEFAULT_ENABLED: True, - }, - SENSORS_RATES[0]: { - SENSOR_NAME: "Download Speed", - SENSOR_UNIT: DATA_RATE_MEGABITS_PER_SECOND, - SENSOR_FACTOR: 125000, - SENSOR_ICON: "mdi:download-network", - }, - SENSORS_RATES[1]: { - SENSOR_NAME: "Upload Speed", - SENSOR_UNIT: DATA_RATE_MEGABITS_PER_SECOND, - SENSOR_FACTOR: 125000, - SENSOR_ICON: "mdi:upload-network", - }, - SENSORS_BYTES[0]: { - SENSOR_NAME: "Download", - SENSOR_UNIT: DATA_GIGABYTES, - SENSOR_FACTOR: 1000000000, - SENSOR_ICON: "mdi:download", - }, - SENSORS_BYTES[1]: { - SENSOR_NAME: "Upload", - SENSOR_UNIT: DATA_GIGABYTES, - SENSOR_FACTOR: 1000000000, - SENSOR_ICON: "mdi:upload", - }, - SENSORS_LOAD_AVG[0]: { - SENSOR_NAME: "Load Avg (1m)", - SENSOR_ICON: "mdi:cpu-32-bit", - }, - SENSORS_LOAD_AVG[1]: { - SENSOR_NAME: "Load Avg (5m)", - SENSOR_ICON: "mdi:cpu-32-bit", - }, - SENSORS_LOAD_AVG[2]: { - SENSOR_NAME: "Load Avg (15m)", - SENSOR_ICON: "mdi:cpu-32-bit", - }, -} +CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( + AsusWrtSensorEntityDescription( + key=SENSORS_CONNECTED_DEVICE[0], + name="Devices Connected", + icon="mdi:router-network", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=UNIT_DEVICES, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_RATES[0], + name="Download Speed", + icon="mdi:download-network", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + entity_registry_enabled_default=False, + factor=125000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_RATES[1], + name="Upload Speed", + icon="mdi:upload-network", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + entity_registry_enabled_default=False, + factor=125000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_BYTES[0], + name="Download", + icon="mdi:download", + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=DATA_GIGABYTES, + entity_registry_enabled_default=False, + factor=1000000000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_BYTES[1], + name="Upload", + icon="mdi:upload", + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=DATA_GIGABYTES, + entity_registry_enabled_default=False, + factor=1000000000, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[0], + name="Load Avg (1m)", + icon="mdi:cpu-32-bit", + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[1], + name="Load Avg (5m)", + icon="mdi:cpu-32-bit", + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_LOAD_AVG[2], + name="Load Avg (15m)", + icon="mdi:cpu-32-bit", + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + factor=1, + precision=1, + ), +) _LOGGER = logging.getLogger(__name__) @@ -95,13 +127,13 @@ async def async_setup_entry( for sensor_data in router.sensors_coordinator.values(): coordinator = sensor_data[KEY_COORDINATOR] sensors = sensor_data[KEY_SENSORS] - for sensor_key in sensors: - if sensor_key in CONNECTION_SENSORS: - entities.append( - AsusWrtSensor( - coordinator, router, sensor_key, CONNECTION_SENSORS[sensor_key] - ) - ) + entities.extend( + [ + AsusWrtSensor(coordinator, router, sensor_descr) + for sensor_descr in CONNECTION_SENSORS + if sensor_descr.key in sensors + ] + ) async_add_entities(entities, True) @@ -113,39 +145,22 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): self, coordinator: DataUpdateCoordinator, router: AsusWrtRouter, - sensor_type: str, - sensor_def: dict[str, Any], + description: AsusWrtSensorEntityDescription, ) -> None: """Initialize a AsusWrt sensor.""" super().__init__(coordinator) - self._router = router - self._sensor_type = sensor_type - self._attr_name = f"{DEFAULT_PREFIX} {sensor_def[SENSOR_NAME]}" - self._factor = sensor_def.get(SENSOR_FACTOR) + self.entity_description: AsusWrtSensorEntityDescription = description + + self._attr_name = f"{DEFAULT_PREFIX} {description.name}" self._attr_unique_id = f"{DOMAIN} {self.name}" - self._attr_entity_registry_enabled_default = sensor_def.get( - SENSOR_DEFAULT_ENABLED, False - ) - self._attr_unit_of_measurement = sensor_def.get(SENSOR_UNIT) - self._attr_icon = sensor_def.get(SENSOR_ICON) - self._attr_device_class = sensor_def.get(SENSOR_DEVICE_CLASS) + self._attr_device_info = router.device_info + self._attr_extra_state_attributes = {"hostname": router.host} @property - def state(self) -> str: + def native_value(self) -> float | str | None: """Return current state.""" - state = self.coordinator.data.get(self._sensor_type) - if state is None: - return None - if self._factor and isinstance(state, Number): - return round(state / self._factor, 2) + descr = self.entity_description + state = self.coordinator.data.get(descr.key) + if state is not None and descr.factor and isinstance(state, Real): + return round(state / descr.factor, descr.precision) return state - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the attributes.""" - return {"hostname": self._router.host} - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return self._router.device_info diff --git a/homeassistant/components/asuswrt/translations/es-419.json b/homeassistant/components/asuswrt/translations/es-419.json new file mode 100644 index 00000000000..1c6b80588cd --- /dev/null +++ b/homeassistant/components/asuswrt/translations/es-419.json @@ -0,0 +1,33 @@ +{ + "config": { + "error": { + "pwd_and_ssh": "Solo proporcione la contrase\u00f1a o el archivo de clave SSH", + "pwd_or_ssh": "Proporcione la contrase\u00f1a o el archivo de clave SSH", + "ssh_not_file": "No se encontr\u00f3 el archivo de clave SSH" + }, + "step": { + "user": { + "data": { + "protocol": "Protocolo de comunicaci\u00f3n a utilizar", + "ssh_key": "Ruta a su archivo de clave SSH (en lugar de contrase\u00f1a)" + }, + "description": "Establezca el par\u00e1metro requerido para conectarse a su enrutador", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segundos de espera antes de considerar un dispositivo ausente", + "dnsmasq": "La ubicaci\u00f3n en el enrutador de los archivos dnsmasq.leases", + "interface": "La interfaz de la que desea obtener estad\u00edsticas (por ejemplo, eth0, eth1, etc.)", + "require_ip": "Los dispositivos deben tener IP (para el modo de punto de acceso)", + "track_unknown": "Seguimiento de dispositivos desconocidos / sin nombre" + }, + "title": "Opciones de AsusWRT" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/zh-Hans.json b/homeassistant/components/asuswrt/translations/zh-Hans.json new file mode 100644 index 00000000000..69f7bf98df3 --- /dev/null +++ b/homeassistant/components/asuswrt/translations/zh-Hans.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86\uff0c\u4e14\u53ea\u80fd\u914d\u7f6e\u4e00\u6b21\u3002" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_host": "\u65e0\u6548\u7684\u4e3b\u673a\u5730\u5740\u6216 IP \u5730\u5740", + "pwd_and_ssh": "\u53ea\u63d0\u4f9b\u5bc6\u7801\u6216 SSH \u5bc6\u94a5\u6587\u4ef6", + "pwd_or_ssh": "\u8bf7\u63d0\u4f9b\u5bc6\u7801\u6216 SSH \u5bc6\u94a5\u6587\u4ef6", + "ssh_not_file": "\u672a\u627e\u5230 SSH \u5bc6\u94a5\u6587\u4ef6", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "mode": "\u4f7f\u7528\u6a21\u5f0f", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "protocol": "\u901a\u4fe1\u534f\u8bae", + "ssh_key": "SSH \u5bc6\u94a5\u6587\u4ef6\u8def\u5f84 (\u4e0d\u662f\u5bc6\u7801)", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8bbe\u7f6e\u8fde\u63a5\u5230\u8def\u7531\u5668\u6240\u9700\u7684\u53c2\u6570", + "title": "AsusWRT" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "\u7b49\u5f85\u591a\u5c11\u79d2\u540e\u5219\u5224\u5b9a\u8bbe\u5907\u79bb\u5f00", + "dnsmasq": "\u8def\u7531\u5668\u4e2d\u7684 dnsmasq.leases \u6587\u4ef6\u4f4d\u7f6e", + "interface": "\u60f3\u8981\u76d1\u6d4b\u7684\u7aef\u53e3(\u4f8b\u5982: eth0,eth1 \u7b49)", + "require_ip": "\u8bbe\u5907\u5fc5\u987b\u5177\u6709 IP (\u7528\u4e8e\u63a5\u5165\u70b9\u6a21\u5f0f)" + }, + "title": "AsusWRT \u9009\u9879" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index 6bafd59ab82..1a5e2a597cf 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -47,7 +47,7 @@ class AtagThermostat(AtagEntity, ClimateEntity): self._attr_temperature_unit = coordinator.data.climate.temp_unit @property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> str | None: # type: ignore[override] """Return hvac operation ie. heat, cool mode.""" if self.coordinator.data.climate.hvac_mode in HVAC_MODES: return self.coordinator.data.climate.hvac_mode diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index 93164bd14bf..386b5999712 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -37,22 +37,24 @@ class AtagSensor(AtagEntity, SensorEntity): """Initialize Atag sensor.""" super().__init__(coordinator, SENSORS[sensor]) self._attr_name = sensor - if coordinator.data.report[self._id].sensorclass in [ + if coordinator.data.report[self._id].sensorclass in ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, - ]: + ): self._attr_device_class = coordinator.data.report[self._id].sensorclass - if coordinator.data.report[self._id].measure in [ + if coordinator.data.report[self._id].measure in ( PRESSURE_BAR, TEMP_CELSIUS, TEMP_FAHRENHEIT, PERCENTAGE, TIME_HOURS, - ]: - self._attr_unit_of_measurement = coordinator.data.report[self._id].measure + ): + self._attr_native_unit_of_measurement = coordinator.data.report[ + self._id + ].measure @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.coordinator.data.report[self._id].state diff --git a/homeassistant/components/atag/translations/es-419.json b/homeassistant/components/atag/translations/es-419.json index 92e7fae8703..358bc754c97 100644 --- a/homeassistant/components/atag/translations/es-419.json +++ b/homeassistant/components/atag/translations/es-419.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Solo se puede agregar un dispositivo Atag a Home Assistant" }, + "error": { + "unauthorized": "Emparejamiento denegado, verifique el dispositivo para obtener una solicitud de autenticaci\u00f3n" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/atag/translations/hu.json b/homeassistant/components/atag/translations/hu.json index 134f3bedfe8..8c3b4a055b0 100644 --- a/homeassistant/components/atag/translations/hu.json +++ b/homeassistant/components/atag/translations/hu.json @@ -4,14 +4,16 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unauthorized": "A p\u00e1ros\u00edt\u00e1s megtagadva, ellen\u0151rizze az eszk\u00f6z hiteles\u00edt\u00e9si k\u00e9r\u00e9s\u00e9t" }, "step": { "user": { "data": { "host": "Hoszt", "port": "Port" - } + }, + "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" } } } diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index 7295a9cee41..59d193ec8e2 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -20,7 +21,7 @@ from homeassistant.const import ( POWER_WATT, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -88,16 +89,12 @@ class AtomeData: self._is_connected = None self._day_usage = None self._day_price = None - self._day_last_reset = None self._week_usage = None self._week_price = None - self._week_last_reset = None self._month_usage = None self._month_price = None - self._month_last_reset = None self._year_usage = None self._year_price = None - self._year_last_reset = None @property def live_power(self): @@ -142,11 +139,6 @@ class AtomeData: """Return latest daily usage value.""" return self._day_price - @property - def day_last_reset(self): - """Return latest daily last reset.""" - return self._day_last_reset - @Throttle(DAILY_SCAN_INTERVAL) def update_day_usage(self): """Return current daily power usage.""" @@ -154,7 +146,6 @@ class AtomeData: values = self.atome_client.get_consumption(DAILY_TYPE) self._day_usage = values["total"] / 1000 self._day_price = values["price"] - self._day_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome daily data. Got: %d", self._day_usage) except KeyError as error: @@ -170,11 +161,6 @@ class AtomeData: """Return latest weekly usage value.""" return self._week_price - @property - def week_last_reset(self): - """Return latest weekly last reset value.""" - return self._week_last_reset - @Throttle(WEEKLY_SCAN_INTERVAL) def update_week_usage(self): """Return current weekly power usage.""" @@ -182,7 +168,6 @@ class AtomeData: values = self.atome_client.get_consumption(WEEKLY_TYPE) self._week_usage = values["total"] / 1000 self._week_price = values["price"] - self._week_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome weekly data. Got: %d", self._week_usage) except KeyError as error: @@ -198,11 +183,6 @@ class AtomeData: """Return latest monthly usage value.""" return self._month_price - @property - def month_last_reset(self): - """Return latest monthly last reset value.""" - return self._month_last_reset - @Throttle(MONTHLY_SCAN_INTERVAL) def update_month_usage(self): """Return current monthly power usage.""" @@ -210,7 +190,6 @@ class AtomeData: values = self.atome_client.get_consumption(MONTHLY_TYPE) self._month_usage = values["total"] / 1000 self._month_price = values["price"] - self._month_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome monthly data. Got: %d", self._month_usage) except KeyError as error: @@ -226,11 +205,6 @@ class AtomeData: """Return latest yearly usage value.""" return self._year_price - @property - def year_last_reset(self): - """Return latest yearly last reset value.""" - return self._year_last_reset - @Throttle(YEARLY_SCAN_INTERVAL) def update_year_usage(self): """Return current yearly power usage.""" @@ -238,7 +212,6 @@ class AtomeData: values = self.atome_client.get_consumption(YEARLY_TYPE) self._year_usage = values["total"] / 1000 self._year_price = values["price"] - self._year_last_reset = dt_util.parse_datetime(values["startPeriod"]) _LOGGER.debug("Updating Atome yearly data. Got: %d", self._year_usage) except KeyError as error: @@ -254,14 +227,15 @@ class AtomeSensor(SensorEntity): self._data = data self._sensor_type = sensor_type - self._attr_state_class = STATE_CLASS_MEASUREMENT if sensor_type == LIVE_TYPE: self._attr_device_class = DEVICE_CLASS_POWER - self._attr_unit_of_measurement = POWER_WATT + self._attr_native_unit_of_measurement = POWER_WATT + self._attr_state_class = STATE_CLASS_MEASUREMENT else: self._attr_device_class = DEVICE_CLASS_ENERGY - self._attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING def update(self): """Update device state.""" @@ -269,16 +243,13 @@ class AtomeSensor(SensorEntity): update_function() if self._sensor_type == LIVE_TYPE: - self._attr_state = self._data.live_power + self._attr_native_value = self._data.live_power self._attr_extra_state_attributes = { "subscribed_power": self._data.subscribed_power, "is_connected": self._data.is_connected, } else: - self._attr_state = getattr(self._data, f"{self._sensor_type}_usage") - self._attr_last_reset = dt_util.as_utc( - getattr(self._data, f"{self._sensor_type}_last_reset") - ) + self._attr_native_value = getattr(self._data, f"{self._sensor_type}_usage") self._attr_extra_state_attributes = { "price": getattr(self._data, f"{self._sensor_type}_price") } diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 6bb47a06eee..6f9ecf1b182 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,4 +1,5 @@ """Support for August doorbell camera.""" +from __future__ import annotations from yalexs.activity import ActivityType from yalexs.util import update_doorbell_image_from_activity @@ -68,7 +69,9 @@ class AugustCamera(AugustEntityMixin, Camera): if doorbell_activity is not None: update_doorbell_image_from_activity(self._detail, doorbell_activity) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" self._update_from_data() diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 74caa4b4a78..fc365102926 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -13,6 +13,10 @@ "hostname": "connect", "macaddress": "B8B7F1*" }, + { + "hostname": "connect", + "macaddress": "2C9FFB*" + }, { "hostname": "august*", "macaddress": "E076D0*" diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index a174964f349..b6d93d3b3b1 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -146,7 +146,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._attr_available = True if lock_activity is not None: - self._attr_state = lock_activity.operated_by + self._attr_native_value = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad self._operated_autorelock = lock_activity.operated_autorelock @@ -208,7 +208,7 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity): """Representation of an August sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, data, sensor_type, device, old_device): """Initialize the sensor.""" @@ -223,8 +223,8 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity): def _update_from_data(self): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] - self._attr_state = state_provider(self._detail) - self._attr_available = self._attr_state is not None + self._attr_native_value = state_provider(self._detail) + self._attr_available = self._attr_native_value is not None @property def old_unique_id(self) -> str: diff --git a/homeassistant/components/august/translations/hu.json b/homeassistant/components/august/translations/hu.json index fec6ad93b26..aeaef514e71 100644 --- a/homeassistant/components/august/translations/hu.json +++ b/homeassistant/components/august/translations/hu.json @@ -30,6 +30,7 @@ "data": { "code": "Ellen\u0151rz\u0151 k\u00f3d" }, + "description": "K\u00e9rj\u00fck, ellen\u0151rizze a {login_method} ({username}), \u00e9s \u00edrja be al\u00e1bb az ellen\u0151rz\u0151 k\u00f3dot", "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s" } } diff --git a/homeassistant/components/august/translations/zh-Hans.json b/homeassistant/components/august/translations/zh-Hans.json new file mode 100644 index 00000000000..b932dae2511 --- /dev/null +++ b/homeassistant/components/august/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_validate": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user_validate": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index 576ccc1275b..0c80cda4bd5 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -7,7 +7,15 @@ from aiohttp import ClientError from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import ( @@ -18,9 +26,6 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( ATTR_ENTRY_TYPE, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, ATTRIBUTION, AURORA_API, CONF_THRESHOLD, diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index cd6f54a3d0c..8ce6bbad3f9 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -3,9 +3,6 @@ DOMAIN = "aurora" COORDINATOR = "coordinator" AURORA_API = "aurora_api" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" ATTR_ENTRY_TYPE = "entry_type" DEFAULT_POLLING_INTERVAL = 5 CONF_THRESHOLD = "forecast_threshold" diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index 76be6ca97f8..96bdbbf1370 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -22,9 +22,9 @@ async def async_setup_entry(hass, entry, async_add_entries): class AuroraSensor(AuroraEntity, SensorEntity): """Implementation of an aurora sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property - def state(self): + def native_value(self): """Return % chance the aurora is visible.""" return self.coordinator.data diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 9c798b8e6d4..b1bcec18796 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -51,7 +51,7 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): """Representation of a Sensor.""" _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT _attr_device_class = DEVICE_CLASS_POWER def __init__(self, client, name, typename): @@ -68,7 +68,7 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): self.client.connect() # read ADC channel 3 (grid power output) power_watts = self.client.measure(3, True) - self._attr_state = round(power_watts, 1) + self._attr_native_value = round(power_watts, 1) except AuroraError as error: # aurorapy does not have different exceptions (yet) for dealing # with timeout vs other comms errors. @@ -82,7 +82,7 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): _LOGGER.debug("No response from inverter (could be dark)") else: raise error - self._attr_state = None + self._attr_native_value = None finally: if self.client.serline.isOpen(): self.client.close() diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index c951e652356..b01e6e0c01e 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -248,10 +248,10 @@ class LoginFlowResourceView(HomeAssistantView): if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: # @log_invalid_auth does not work here since it returns HTTP 200 # need manually log failed login attempts - if result.get("errors") is not None and result["errors"].get("base") in [ + if result.get("errors") is not None and result["errors"].get("base") in ( "invalid_auth", "invalid_code", - ]: + ): await process_wrong_login(request) return self.json(_prepare_result_json(result)) diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 968587c3b10..3b46d3b2317 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -144,7 +144,7 @@ class AwairSensor(CoordinatorEntity, SensorEntity): return False @property - def state(self) -> float: + def native_value(self) -> float: """Return the state, rounding off to reasonable values.""" state: float @@ -175,7 +175,7 @@ class AwairSensor(CoordinatorEntity, SensorEntity): return SENSOR_TYPES[self._kind][ATTR_DEVICE_CLASS] @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit the value is expressed in.""" return SENSOR_TYPES[self._kind][ATTR_UNIT] diff --git a/homeassistant/components/awair/translations/es-419.json b/homeassistant/components/awair/translations/es-419.json new file mode 100644 index 00000000000..f487cd397c4 --- /dev/null +++ b/homeassistant/components/awair/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "reauth": { + "description": "Vuelva a ingresar su token de acceso de desarrollador de Awair." + }, + "user": { + "description": "Debe registrarse para obtener un token de acceso de desarrollador de Awair en: https://developer.getawair.com/onboard/login" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/hu.json b/homeassistant/components/awair/translations/hu.json index 53827adf344..f465186a95b 100644 --- a/homeassistant/components/awair/translations/hu.json +++ b/homeassistant/components/awair/translations/hu.json @@ -21,7 +21,8 @@ "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "email": "E-mail" - } + }, + "description": "Regisztr\u00e1lnia kell az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokenj\u00e9hez a k\u00f6vetkez\u0151 c\u00edmen: https://developer.getawair.com/onboard/login" } } } diff --git a/homeassistant/components/axis/translations/es-419.json b/homeassistant/components/axis/translations/es-419.json index 0e1c1e99b36..39d216dd475 100644 --- a/homeassistant/components/axis/translations/es-419.json +++ b/homeassistant/components/axis/translations/es-419.json @@ -21,5 +21,15 @@ "title": "Configurar dispositivo Axis" } } + }, + "options": { + "step": { + "configure_stream": { + "data": { + "stream_profile": "Seleccionar perfil de transmisi\u00f3n para usar" + }, + "title": "Opciones de transmisi\u00f3n de video del dispositivo Axis" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/axis/translations/hu.json b/homeassistant/components/axis/translations/hu.json index 972690ede97..709de5851ad 100644 --- a/homeassistant/components/axis/translations/hu.json +++ b/homeassistant/components/axis/translations/hu.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "link_local_address": "A linkek helyi c\u00edmei nem t\u00e1mogatottak", + "not_axis_device": "A felfedezett eszk\u00f6z nem Axis eszk\u00f6z" }, "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", @@ -17,7 +19,8 @@ "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "\u00c1ll\u00edtsa be az Axis eszk\u00f6zt" } } }, @@ -26,7 +29,8 @@ "configure_stream": { "data": { "stream_profile": "V\u00e1lassza ki a haszn\u00e1lni k\u00edv\u00e1nt adatfolyam-profilt" - } + }, + "title": "Axis eszk\u00f6z vide\u00f3 stream opci\u00f3k" } } } diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index d7589cf5014..67d472abc1e 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -71,7 +71,7 @@ class AzureDevOpsSensor(AzureDevOpsDeviceEntity, SensorEntity): unit_of_measurement: str = "", ) -> None: """Initialize Azure DevOps sensor.""" - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self.client = client self.organization = organization self.project = project @@ -107,7 +107,7 @@ class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): _LOGGER.warning(exception) self._attr_available = False return False - self._attr_state = build.build_number + self._attr_native_value = build.build_number self._attr_extra_state_attributes = { "definition_id": build.definition.id, "definition_name": build.definition.name, diff --git a/homeassistant/components/azure_devops/translations/es-419.json b/homeassistant/components/azure_devops/translations/es-419.json new file mode 100644 index 00000000000..7ac7d2a930d --- /dev/null +++ b/homeassistant/components/azure_devops/translations/es-419.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "project_error": "No se pudo obtener la informaci\u00f3n del proyecto." + }, + "flow_title": "{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token de acceso personal (PAT)" + }, + "description": "Error de autenticaci\u00f3n para {project_url}. Ingrese sus credenciales actuales.", + "title": "Reautenticaci\u00f3n" + }, + "user": { + "data": { + "organization": "Organizaci\u00f3n", + "personal_access_token": "Token de acceso personal (PAT)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/hu.json b/homeassistant/components/azure_devops/translations/hu.json index f85c6795fd5..e42ebc8d8e2 100644 --- a/homeassistant/components/azure_devops/translations/hu.json +++ b/homeassistant/components/azure_devops/translations/hu.json @@ -6,14 +6,25 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "project_error": "Nem siker\u00fclt lek\u00e9rni a projekt adatait." }, "flow_title": "{project_url}", "step": { "reauth": { - "description": "A(z) {project_url} hiteles\u00edt\u00e9se nem siker\u00fclt. K\u00e9rj\u00fck, adja meg jelenlegi hiteles\u00edt\u0151 adatait." + "data": { + "personal_access_token": "Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token (PAT)" + }, + "description": "A(z) {project_url} hiteles\u00edt\u00e9se nem siker\u00fclt. K\u00e9rj\u00fck, adja meg jelenlegi hiteles\u00edt\u0151 adatait.", + "title": "\u00dajrahiteles\u00edt\u00e9s" }, "user": { + "data": { + "organization": "Szervezet", + "personal_access_token": "Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token (PAT)", + "project": "Projekt" + }, + "description": "\u00c1ll\u00edtson be egy Azure DevOps-p\u00e9ld\u00e1nyt a projekt el\u00e9r\u00e9s\u00e9hez. Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si token csak mag\u00e1nprojekthez sz\u00fcks\u00e9ges.", "title": "Azure DevOps Project hozz\u00e1ad\u00e1sa" } } diff --git a/homeassistant/components/azure_devops/translations/zh-Hans.json b/homeassistant/components/azure_devops/translations/zh-Hans.json index b0c629646e2..d6a6e62e27c 100644 --- a/homeassistant/components/azure_devops/translations/zh-Hans.json +++ b/homeassistant/components/azure_devops/translations/zh-Hans.json @@ -1,8 +1,32 @@ { "config": { + "abort": { + "already_configured": "\u8d26\u6237\u5df2\u88ab\u914d\u7f6e", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", - "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548" + "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548", + "project_error": "\u65e0\u6cd5\u83b7\u53d6\u9879\u76ee\u4fe1\u606f\u3002" + }, + "flow_title": "{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c (PAT)" + }, + "description": "{project_url} \u8eab\u4efd\u9a8c\u8bc1\u5931\u8d25\u3002\u8bf7\u8f93\u5165\u60a8\u5f53\u524d\u7684\u51ed\u636e\u3002", + "title": "\u91cd\u9a8c\u8bc1" + }, + "user": { + "data": { + "organization": "\u7ec4\u7ec7", + "personal_access_token": "\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c (PAT)", + "project": "\u9879\u76ee" + }, + "description": "\u8bbe\u7f6e Azure DevOps \u5b9e\u4f8b\u4ee5\u8bbf\u95ee\u60a8\u7684\u9879\u76ee\u3002\u79c1\u4eba\u9879\u76ee\u624d\u9700\u8981\u63d0\u4f9b\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c\u3002", + "title": "\u6dfb\u52a0 Azure DevOps \u9879\u76ee" + } } } } \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 1c9add1bd8b..9bae21ec43b 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -5,9 +5,9 @@ import asyncio import json import logging import time -from typing import Any +from typing import Any, Callable -from azure.eventhub import EventData +from azure.eventhub import EventData, EventDataBatch from azure.eventhub.aio import EventHubProducerClient, EventHubSharedKeyCredential from azure.eventhub.exceptions import EventHubError import voluptuous as vol @@ -109,14 +109,16 @@ class AzureEventHub: ) -> None: """Initialize the listener.""" self.hass = hass - self.queue = asyncio.PriorityQueue() + self.queue: asyncio.PriorityQueue[ # pylint: disable=unsubscriptable-object + tuple[int, tuple[float, Event | None]] + ] = asyncio.PriorityQueue() self._client_args = client_args self._conn_str_client = conn_str_client self._entities_filter = entities_filter self._send_interval = send_interval self._max_delay = max_delay + send_interval - self._listener_remover = None - self._next_send_remover = None + self._listener_remover: Callable[[], None] | None = None + self._next_send_remover: Callable[[], None] | None = None self.shutdown = False async def async_start(self) -> None: @@ -169,7 +171,7 @@ class AzureEventHub: self.hass, self._send_interval, self.async_send ) - async def fill_batch(self, client) -> None: + async def fill_batch(self, client) -> tuple[EventDataBatch, int]: """Return a batch of events formatted for writing. Uses get_nowait instead of await get, because the functions batches and doesn't wait for each single event, the send function is called. @@ -207,7 +209,7 @@ class AzureEventHub: return event_batch, dequeue_count - def _event_to_filtered_event_data(self, event: Event) -> None: + def _event_to_filtered_event_data(self, event: Event) -> EventData | None: """Filter event states and create EventData object.""" state = event.data.get("new_state") if ( diff --git a/homeassistant/components/azure_event_hub/const.py b/homeassistant/components/azure_event_hub/const.py index 1786bb5cbf2..fdb5180fe4e 100644 --- a/homeassistant/components/azure_event_hub/const.py +++ b/homeassistant/components/azure_event_hub/const.py @@ -1,4 +1,8 @@ """Constants and shared schema for the Azure Event Hub integration.""" +from __future__ import annotations + +from typing import Any + DOMAIN = "azure_event_hub" CONF_EVENT_HUB_NAMESPACE = "event_hub_namespace" @@ -10,4 +14,4 @@ CONF_SEND_INTERVAL = "send_interval" CONF_MAX_DELAY = "max_delay" CONF_FILTER = "filter" -ADDITIONAL_ARGS = {"logging_enable": False} +ADDITIONAL_ARGS: dict[str, Any] = {"logging_enable": False} diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index b0ace5fa675..d2c06f45875 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -1,4 +1,6 @@ """Support for Bbox Bouygues Modem Router.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,11 @@ import pybbox import requests 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_VARIABLES, @@ -26,36 +32,52 @@ DEFAULT_NAME = "Bbox" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -# Sensor types are defined like so: Name, unit, icon -SENSOR_TYPES = { - "down_max_bandwidth": [ - "Maximum Download Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:download", - ], - "up_max_bandwidth": [ - "Maximum Upload Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:upload", - ], - "current_down_bandwidth": [ - "Currently Used Download Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:download", - ], - "current_up_bandwidth": [ - "Currently Used Upload Bandwidth", - DATA_RATE_MEGABITS_PER_SECOND, - "mdi:upload", - ], - "uptime": ["Uptime", None, "mdi:clock"], - "number_of_reboots": ["Number of reboot", None, "mdi:restart"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="down_max_bandwidth", + name="Maximum Download Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:download", + ), + SensorEntityDescription( + key="up_max_bandwidth", + name="Maximum Upload Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:upload", + ), + SensorEntityDescription( + key="current_down_bandwidth", + name="Currently Used Download Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:download", + ), + SensorEntityDescription( + key="current_up_bandwidth", + name="Currently Used Upload Bandwidth", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + icon="mdi:upload", + ), + SensorEntityDescription( + key="number_of_reboots", + name="Number of reboot", + icon="mdi:restart", + ), +) + +SENSOR_TYPES_UPTIME: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="uptime", + name="Uptime", + icon="mdi:clock", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in (*SENSOR_TYPES, *SENSOR_TYPES_UPTIME)] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } @@ -75,14 +97,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] - sensors = [] - for variable in config[CONF_MONITORED_VARIABLES]: - if variable == "uptime": - sensors.append(BboxUptimeSensor(bbox_data, variable, name)) - else: - sensors.append(BboxSensor(bbox_data, variable, name)) + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities: list[BboxSensor | BboxUptimeSensor] = [ + BboxSensor(bbox_data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] + entities.extend( + [ + BboxUptimeSensor(bbox_data, name, description) + for description in SENSOR_TYPES_UPTIME + if description.key in monitored_variables + ] + ) - add_entities(sensors, True) + add_entities(entities, True) class BboxUptimeSensor(SensorEntity): @@ -91,11 +120,10 @@ class BboxUptimeSensor(SensorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_device_class = DEVICE_CLASS_TIMESTAMP - def __init__(self, bbox_data, sensor_type, name): + def __init__(self, bbox_data, name, description: SensorEntityDescription): """Initialize the sensor.""" - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bbox_data = bbox_data def update(self): @@ -104,7 +132,7 @@ class BboxUptimeSensor(SensorEntity): uptime = utcnow() - timedelta( seconds=self.bbox_data.router_infos["device"]["uptime"] ) - self._attr_state = uptime.replace(microsecond=0).isoformat() + self._attr_native_value = uptime.replace(microsecond=0).isoformat() class BboxSensor(SensorEntity): @@ -112,31 +140,36 @@ class BboxSensor(SensorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - def __init__(self, bbox_data, sensor_type, name): + def __init__(self, bbox_data, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.type = sensor_type - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bbox_data = bbox_data def update(self): """Get the latest data from Bbox and update the state.""" self.bbox_data.update() - if self.type == "down_max_bandwidth": - self._attr_state = round( + sensor_type = self.entity_description.key + if sensor_type == "down_max_bandwidth": + self._attr_native_value = round( self.bbox_data.data["rx"]["maxBandwidth"] / 1000, 2 ) - elif self.type == "up_max_bandwidth": - self._attr_state = round( + elif sensor_type == "up_max_bandwidth": + self._attr_native_value = round( self.bbox_data.data["tx"]["maxBandwidth"] / 1000, 2 ) - elif self.type == "current_down_bandwidth": - self._attr_state = round(self.bbox_data.data["rx"]["bandwidth"] / 1000, 2) - elif self.type == "current_up_bandwidth": - self._attr_state = round(self.bbox_data.data["tx"]["bandwidth"] / 1000, 2) - elif self.type == "number_of_reboots": - self._attr_state = self.bbox_data.router_infos["device"]["numberofboots"] + elif sensor_type == "current_down_bandwidth": + self._attr_native_value = round( + self.bbox_data.data["rx"]["bandwidth"] / 1000, 2 + ) + elif sensor_type == "current_up_bandwidth": + self._attr_native_value = round( + self.bbox_data.data["tx"]["bandwidth"] / 1000, 2 + ) + elif sensor_type == "number_of_reboots": + self._attr_native_value = self.bbox_data.router_infos["device"][ + "numberofboots" + ] class BboxData: diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 2ed6b71be41..9ec81956c56 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -63,17 +63,17 @@ class BeewiSmartclimSensor(SensorEntity): self._poller = poller self._attr_name = name self._device = device - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit self._attr_device_class = self._device self._attr_unique_id = f"{mac}_{device}" def update(self): """Fetch new state data from the poller.""" self._poller.update_sensor() - self._attr_state = None + self._attr_native_value = None if self._device == DEVICE_CLASS_TEMPERATURE: - self._attr_state = self._poller.get_temperature() + self._attr_native_value = self._poller.get_temperature() if self._device == DEVICE_CLASS_HUMIDITY: - self._attr_state = self._poller.get_humidity() + self._attr_native_value = self._poller.get_humidity() if self._device == DEVICE_CLASS_BATTERY: - self._attr_state = self._poller.get_battery() + self._attr_native_value = self._poller.get_battery() diff --git a/homeassistant/components/bh1750/sensor.py b/homeassistant/components/bh1750/sensor.py index 8a1f8c60ccf..ad5ca13684a 100644 --- a/homeassistant/components/bh1750/sensor.py +++ b/homeassistant/components/bh1750/sensor.py @@ -101,7 +101,7 @@ class BH1750Sensor(SensorEntity): def __init__(self, bh1750_sensor, name, unit, multiplier=1.0): """Initialize the sensor.""" self._attr_name = name - self._attr_unit_of_measurement = unit + self._attr_native_unit_of_measurement = unit self._multiplier = multiplier self.bh1750_sensor = bh1750_sensor @@ -109,7 +109,7 @@ class BH1750Sensor(SensorEntity): """Get the latest data from the BH1750 and update the states.""" await self.hass.async_add_executor_job(self.bh1750_sensor.update) if self.bh1750_sensor.sample_ok and self.bh1750_sensor.light_level >= 0: - self._attr_state = int( + self._attr_native_value = int( round(self.bh1750_sensor.light_level * self._multiplier) ) else: diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 2bd5de34d51..87d574fc4b0 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -92,6 +92,9 @@ DEVICE_CLASS_SMOKE = "smoke" # On means sound detected, Off means no sound (clear) DEVICE_CLASS_SOUND = "sound" +# On means update available, Off means up-to-date +DEVICE_CLASS_UPDATE = "update" + # On means vibration detected, Off means no vibration DEVICE_CLASS_VIBRATION = "vibration" @@ -121,6 +124,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ] diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index eed5c3f5896..a6b9d3ffb8b 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -37,11 +37,14 @@ from . import ( DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, DOMAIN, ) +# mypy: disallow-any-generics + DEVICE_CLASS_NONE = "none" CONF_IS_BAT_LOW = "is_bat_low" @@ -82,6 +85,8 @@ CONF_IS_SMOKE = "is_smoke" CONF_IS_NO_SMOKE = "is_no_smoke" CONF_IS_SOUND = "is_sound" CONF_IS_NO_SOUND = "is_no_sound" +CONF_IS_UPDATE = "is_update" +CONF_IS_NO_UPDATE = "is_no_update" CONF_IS_VIBRATION = "is_vibration" CONF_IS_NO_VIBRATION = "is_no_vibration" CONF_IS_OPEN = "is_open" @@ -107,6 +112,7 @@ IS_ON = [ CONF_IS_PROBLEM, CONF_IS_SMOKE, CONF_IS_SOUND, + CONF_IS_UPDATE, CONF_IS_UNSAFE, CONF_IS_VIBRATION, CONF_IS_ON, @@ -133,6 +139,7 @@ IS_OFF = [ CONF_IS_NO_PROBLEM, CONF_IS_NO_SMOKE, CONF_IS_NO_SOUND, + CONF_IS_NO_UPDATE, CONF_IS_NO_VIBRATION, CONF_IS_OFF, ] @@ -187,6 +194,7 @@ ENTITY_CONDITIONS = { DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_IS_UNSAFE}, {CONF_TYPE: CONF_IS_NOT_UNSAFE}], DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_IS_SMOKE}, {CONF_TYPE: CONF_IS_NO_SMOKE}], DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_IS_SOUND}, {CONF_TYPE: CONF_IS_NO_SOUND}], + DEVICE_CLASS_UPDATE: [{CONF_TYPE: CONF_IS_UPDATE}, {CONF_TYPE: CONF_IS_NO_UPDATE}], DEVICE_CLASS_VIBRATION: [ {CONF_TYPE: CONF_IS_VIBRATION}, {CONF_TYPE: CONF_IS_NO_VIBRATION}, @@ -260,7 +268,9 @@ def async_condition_from_config( return condition.state_from_config(state_config) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index ad5c26ed04f..a0966b5a018 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -35,6 +35,7 @@ from . import ( DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, DEVICE_CLASS_SOUND, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, DOMAIN, @@ -82,6 +83,8 @@ CONF_SMOKE = "smoke" CONF_NO_SMOKE = "no_smoke" CONF_SOUND = "sound" CONF_NO_SOUND = "no_sound" +CONF_UPDATE = "update" +CONF_NO_UPDATE = "no_update" CONF_VIBRATION = "vibration" CONF_NO_VIBRATION = "no_vibration" CONF_OPENED = "opened" @@ -108,6 +111,7 @@ TURNED_ON = [ CONF_SMOKE, CONF_SOUND, CONF_UNSAFE, + CONF_UPDATE, CONF_VIBRATION, CONF_TURNED_ON, ] @@ -169,6 +173,7 @@ ENTITY_TRIGGERS = { DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_UNSAFE}, {CONF_TYPE: CONF_NOT_UNSAFE}], DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_SMOKE}, {CONF_TYPE: CONF_NO_SMOKE}], DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_SOUND}, {CONF_TYPE: CONF_NO_SOUND}], + DEVICE_CLASS_UPDATE: [{CONF_TYPE: CONF_UPDATE}, {CONF_TYPE: CONF_NO_UPDATE}], DEVICE_CLASS_VIBRATION: [ {CONF_TYPE: CONF_VIBRATION}, {CONF_TYPE: CONF_NO_VIBRATION}, diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 7380d1be576..62b6ec20323 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -38,6 +38,8 @@ "is_no_smoke": "{entity_name} is not detecting smoke", "is_sound": "{entity_name} is detecting sound", "is_no_sound": "{entity_name} is not detecting sound", + "is_update": "{entity_name} has an update available", + "is_no_update": "{entity_name} is up-to-date", "is_vibration": "{entity_name} is detecting vibration", "is_no_vibration": "{entity_name} is not detecting vibration", "is_open": "{entity_name} is open", @@ -82,6 +84,8 @@ "no_smoke": "{entity_name} stopped detecting smoke", "sound": "{entity_name} started detecting sound", "no_sound": "{entity_name} stopped detecting sound", + "update": "{entity_name} got an update available", + "no_update": "{entity_name} became up-to-date", "vibration": "{entity_name} started detecting vibration", "no_vibration": "{entity_name} stopped detecting vibration", "opened": "{entity_name} opened", @@ -175,6 +179,10 @@ "off": "[%key:component::binary_sensor::state::gas::off%]", "on": "[%key:component::binary_sensor::state::gas::on%]" }, + "update": { + "off": "Up-to-date", + "on": "Update available" + }, "vibration": { "off": "[%key:component::binary_sensor::state::gas::off%]", "on": "[%key:component::binary_sensor::state::gas::on%]" diff --git a/homeassistant/components/binary_sensor/translations/ca.json b/homeassistant/components/binary_sensor/translations/ca.json index 9c92a50246a..089f72f51d5 100644 --- a/homeassistant/components/binary_sensor/translations/ca.json +++ b/homeassistant/components/binary_sensor/translations/ca.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} no est\u00e0 detectant cap problema", "is_no_smoke": "{entity_name} no detecta fum", "is_no_sound": "{entity_name} no detecta so", + "is_no_update": "{entity_name} est\u00e0 actualitzat/da", "is_no_vibration": "{entity_name} no detecta vibraci\u00f3", "is_not_bat_low": "Bateria de {entity_name} normal", "is_not_cold": "{entity_name} no est\u00e0 fred", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} est\u00e0 detectant fum", "is_sound": "{entity_name} est\u00e0 detectant so", "is_unsafe": "{entity_name} \u00e9s insegur", + "is_update": "{entity_name} t\u00e9 una actualitzaci\u00f3 disponible", "is_vibration": "{entity_name} est\u00e0 detectant vibraci\u00f3" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} ha deixat de detectar un problema", "no_smoke": "{entity_name} ha deixat de detectar fum", "no_sound": "{entity_name} ha deixat de detectar so", + "no_update": "{entity_name} s'ha actualitzat", "no_vibration": "{entity_name} ha deixat de detectar vibraci\u00f3", "not_bat_low": "Bateria de {entity_name} normal", "not_cold": "{entity_name} es torna no-fred", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} apagat", "turned_on": "{entity_name} enc\u00e8s", "unsafe": "{entity_name} es torna insegur", + "update": "{entity_name} obt\u00e9 una nova actualitzaci\u00f3 disponible", "vibration": "{entity_name} ha comen\u00e7at a detectar vibraci\u00f3" } }, @@ -178,6 +182,10 @@ "off": "Lliure", "on": "Detectat" }, + "update": { + "off": "Actualitzat/da", + "on": "Actualitzaci\u00f3 disponible" + }, "vibration": { "off": "Lliure", "on": "Detectat" diff --git a/homeassistant/components/binary_sensor/translations/cs.json b/homeassistant/components/binary_sensor/translations/cs.json index 90f25332bdb..25b82e54de7 100644 --- a/homeassistant/components/binary_sensor/translations/cs.json +++ b/homeassistant/components/binary_sensor/translations/cs.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} nehl\u00e1s\u00ed probl\u00e9m", "is_no_smoke": "{entity_name} nedetekuje kou\u0159", "is_no_sound": "{entity_name} nedetekuje zvuk", + "is_no_update": "{entity_name} je aktu\u00e1ln\u00ed", "is_no_vibration": "{entity_name} nedetekuje vibrace", "is_not_bat_low": "{entity_name} baterie v norm\u00e1lu", "is_not_cold": "{entity_name} nen\u00ed studen\u00fd", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} detekuje kou\u0159", "is_sound": "{entity_name} detekuje zvuk", "is_unsafe": "{entity_name} nen\u00ed bezpe\u010dno", + "is_update": "{entity_name} m\u00e1 k dispozici aktualizaci", "is_vibration": "{entity_name} detekuje vibrace" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} p\u0159estalo detekovat probl\u00e9m", "no_smoke": "{entity_name} p\u0159estalo detekovat kou\u0159", "no_sound": "{entity_name} p\u0159estalo detekovat zvuk", + "no_update": "{entity_name} se stalo aktu\u00e1ln\u00ed", "no_vibration": "{entity_name} p\u0159estalo detekovat vibrace", "not_bat_low": "{entity_name} baterie v norm\u00e1lu", "not_cold": "{entity_name} p\u0159estal b\u00fdt studen\u00fd", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} vypnuto", "turned_on": "{entity_name} zapnuto", "unsafe": "{entity_name} hl\u00e1s\u00ed ohro\u017een\u00ed", + "update": "{entity_name} m\u00e1 k dispozici aktualizaci", "vibration": "{entity_name} za\u010dalo detekovat vibrace" } }, @@ -178,6 +182,10 @@ "off": "Ticho", "on": "Zachycen zvuk" }, + "update": { + "off": "Aktu\u00e1ln\u00ed", + "on": "Aktualizace k dispozici" + }, "vibration": { "off": "Klid", "on": "Zji\u0161t\u011bny vibrace" diff --git a/homeassistant/components/binary_sensor/translations/de.json b/homeassistant/components/binary_sensor/translations/de.json index a2ef817bedb..21d1eff1ebf 100644 --- a/homeassistant/components/binary_sensor/translations/de.json +++ b/homeassistant/components/binary_sensor/translations/de.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} erkennt kein Problem", "is_no_smoke": "{entity_name} erkennt keinen Rauch", "is_no_sound": "{entity_name} erkennt keine Ger\u00e4usche", + "is_no_update": "{entity_name} ist aktuell", "is_no_vibration": "{entity_name} erkennt keine Vibrationen", "is_not_bat_low": "{entity_name} Batterie ist normal", "is_not_cold": "{entity_name} ist nicht kalt", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} hat Rauch detektiert", "is_sound": "{entity_name} hat Ger\u00e4usche detektiert", "is_unsafe": "{entity_name} ist unsicher", + "is_update": "{entity_name} hat ein Update verf\u00fcgbar", "is_vibration": "{entity_name} erkennt Vibrationen." }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} hat kein Problem mehr erkannt", "no_smoke": "{entity_name} hat keinen Rauch mehr erkannt", "no_sound": "{entity_name} hat keine Ger\u00e4usche mehr erkannt", + "no_update": "{entity_name} wurde auf den neuesten Stand gebracht", "no_vibration": "{entity_name}hat keine Vibrationen mehr erkannt", "not_bat_low": "{entity_name} Batterie normal", "not_cold": "{entity_name} w\u00e4rmte auf", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} ausgeschaltet", "turned_on": "{entity_name} eingeschaltet", "unsafe": "{entity_name} ist unsicher", + "update": "{entity_name} hat ein Update verf\u00fcgbar", "vibration": "{entity_name} detektiert Vibrationen" } }, @@ -178,6 +182,10 @@ "off": "Normal", "on": "Erkannt" }, + "update": { + "off": "Aktuell", + "on": "Update verf\u00fcgbar" + }, "vibration": { "off": "Normal", "on": "Erkannt" diff --git a/homeassistant/components/binary_sensor/translations/en.json b/homeassistant/components/binary_sensor/translations/en.json index 98c8a3a220a..047820498da 100644 --- a/homeassistant/components/binary_sensor/translations/en.json +++ b/homeassistant/components/binary_sensor/translations/en.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} is not detecting problem", "is_no_smoke": "{entity_name} is not detecting smoke", "is_no_sound": "{entity_name} is not detecting sound", + "is_no_update": "{entity_name} is up-to-date", "is_no_vibration": "{entity_name} is not detecting vibration", "is_not_bat_low": "{entity_name} battery is normal", "is_not_cold": "{entity_name} is not cold", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} is detecting smoke", "is_sound": "{entity_name} is detecting sound", "is_unsafe": "{entity_name} is unsafe", + "is_update": "{entity_name} has an update available", "is_vibration": "{entity_name} is detecting vibration" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} stopped detecting problem", "no_smoke": "{entity_name} stopped detecting smoke", "no_sound": "{entity_name} stopped detecting sound", + "no_update": "{entity_name} became up-to-date", "no_vibration": "{entity_name} stopped detecting vibration", "not_bat_low": "{entity_name} battery normal", "not_cold": "{entity_name} became not cold", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} turned off", "turned_on": "{entity_name} turned on", "unsafe": "{entity_name} became unsafe", + "update": "{entity_name} got an update available", "vibration": "{entity_name} started detecting vibration" } }, @@ -178,6 +182,10 @@ "off": "Clear", "on": "Detected" }, + "update": { + "off": "Up-to-date", + "on": "Update available" + }, "vibration": { "off": "Clear", "on": "Detected" diff --git a/homeassistant/components/binary_sensor/translations/es-419.json b/homeassistant/components/binary_sensor/translations/es-419.json index d8cc4219097..dad07f9b771 100644 --- a/homeassistant/components/binary_sensor/translations/es-419.json +++ b/homeassistant/components/binary_sensor/translations/es-419.json @@ -98,6 +98,10 @@ "off": "Normal", "on": "Baja" }, + "battery_charging": { + "off": "No esta cargando", + "on": "Cargando" + }, "cold": { "off": "Normal", "on": "Fr\u00edo" @@ -122,6 +126,10 @@ "off": "Normal", "on": "Caliente" }, + "light": { + "off": "Sin luz", + "on": "Luz detectada" + }, "lock": { "off": "Bloqueado", "on": "Desbloqueado" @@ -134,6 +142,10 @@ "off": "Despejado", "on": "Detectado" }, + "moving": { + "off": "Sin movimiento", + "on": "Movimiento" + }, "occupancy": { "off": "Despejado", "on": "Detectado" @@ -142,6 +154,10 @@ "off": "Cerrado", "on": "Abierto" }, + "plug": { + "off": "Desenchufado", + "on": "Enchufado" + }, "presence": { "off": "Fuera de casa", "on": "En Casa" diff --git a/homeassistant/components/binary_sensor/translations/et.json b/homeassistant/components/binary_sensor/translations/et.json index 99fbec0b89e..2a0172300c9 100644 --- a/homeassistant/components/binary_sensor/translations/et.json +++ b/homeassistant/components/binary_sensor/translations/et.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} ei leia probleemi", "is_no_smoke": "{entity_name} ei tuvasta suitsu", "is_no_sound": "{entity_name} ei tuvasta heli", + "is_no_update": "{entity_name} on ajakohane", "is_no_vibration": "{entity_name} ei tuvasta vibratsiooni", "is_not_bat_low": "{entity_name} aku on laetud", "is_not_cold": "{entity_name} ei ole k\u00fclm", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} tuvastab suitsu", "is_sound": "{entity_name} tuvastab heli", "is_unsafe": "{entity_name} on ebaturvaline", + "is_update": "{entity_name} on saadaval uuendus", "is_vibration": "{entity_name} tuvastab vibratsiooni" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} l\u00f5petas probleemi tuvastamise", "no_smoke": "{entity_name} l\u00f5petas suitsu tuvastamise", "no_sound": "{entity_name} l\u00f5petas heli tuvastamise", + "no_update": "{entity_name} on uuendatud", "no_vibration": "{entity_name} l\u00f5petas vibratsiooni tuvastamise", "not_bat_low": "{entity_name} aku on laetud", "not_cold": "{entity_name} ei ole enam k\u00fclm", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} l\u00fclitus v\u00e4lja", "turned_on": "{entity_name} l\u00fclitus sisse", "unsafe": "{entity_name} on ebaturvaline", + "update": "{entity_name} sai saadavaloleva uuenduse", "vibration": "{entity_name} registreeris vibratsiooni" } }, @@ -178,6 +182,10 @@ "off": "Puudub", "on": "Tuvastatud" }, + "update": { + "off": "Ajakohane", + "on": "Saadaval on uuendus" + }, "vibration": { "off": "Puudub", "on": "Tuvastatud" diff --git a/homeassistant/components/binary_sensor/translations/fr.json b/homeassistant/components/binary_sensor/translations/fr.json index ede13a68dc9..aa0686c0375 100644 --- a/homeassistant/components/binary_sensor/translations/fr.json +++ b/homeassistant/components/binary_sensor/translations/fr.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} ne d\u00e9tecte pas de probl\u00e8me", "is_no_smoke": "{entity_name} ne d\u00e9tecte pas de fum\u00e9e", "is_no_sound": "{entity_name} ne d\u00e9tecte pas de son", + "is_no_update": "{entity_name} est \u00e0 jour", "is_no_vibration": "{entity_name} ne d\u00e9tecte pas de vibration", "is_not_bat_low": "{entity_name} batterie normale", "is_not_cold": "{entity_name} n'est pas froid", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} d\u00e9tecte de la fum\u00e9e", "is_sound": "{entity_name} d\u00e9tecte du son", "is_unsafe": "{entity_name} est dangereux", + "is_update": "{entity_name} a une mise \u00e0 jour disponible", "is_vibration": "{entity_name} d\u00e9tecte des vibrations" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} a cess\u00e9 de d\u00e9tecter un probl\u00e8me", "no_smoke": "{entity_name} a cess\u00e9 de d\u00e9tecter de la fum\u00e9e", "no_sound": "{entity_name} a cess\u00e9 de d\u00e9tecter du bruit", + "no_update": "{entity_name} a \u00e9t\u00e9 mis \u00e0 jour", "no_vibration": "{entity_name} a cess\u00e9 de d\u00e9tecter des vibrations", "not_bat_low": "{entity_name} batterie normale", "not_cold": "{entity_name} n'est plus froid", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} est d\u00e9sactiv\u00e9", "turned_on": "{entity_name} est activ\u00e9", "unsafe": "{entity_name} est devenu dangereux", + "update": "{entity_name} a une mise \u00e0 jour disponible", "vibration": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter les vibrations" } }, @@ -178,6 +182,10 @@ "off": "Non d\u00e9tect\u00e9", "on": "D\u00e9tect\u00e9" }, + "update": { + "off": "\u00c0 jour", + "on": "Mise \u00e0 jour disponible" + }, "vibration": { "off": "RAS", "on": "D\u00e9tect\u00e9e" diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index c345b1a94ce..b0fb2780089 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -52,7 +52,7 @@ }, "light": { "off": "\u05d0\u05d9\u05df \u05d0\u05d5\u05e8", - "on": "\u05d6\u05d5\u05d4\u05d4 \u05d0\u05d5\u05e8" + "on": "\u05d6\u05d5\u05d4\u05ea\u05d4 \u05ea\u05d0\u05d5\u05e8\u05d4" }, "lock": { "off": "\u05e0\u05e2\u05d5\u05dc", @@ -101,6 +101,10 @@ "off": "\u05e0\u05e7\u05d9", "on": "\u05d6\u05d5\u05d4\u05d4" }, + "update": { + "off": "\u05e2\u05d3\u05db\u05e0\u05d9", + "on": "\u05e2\u05d3\u05db\u05d5\u05df \u05d6\u05de\u05d9\u05df" + }, "vibration": { "off": "\u05e0\u05e7\u05d9", "on": "\u05d6\u05d5\u05d4\u05d4" diff --git a/homeassistant/components/binary_sensor/translations/hu.json b/homeassistant/components/binary_sensor/translations/hu.json index c4395ca806c..d8befd7ae35 100644 --- a/homeassistant/components/binary_sensor/translations/hu.json +++ b/homeassistant/components/binary_sensor/translations/hu.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} nem \u00e9szlel probl\u00e9m\u00e1t", "is_no_smoke": "{entity_name} nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", "is_no_sound": "{entity_name} nem \u00e9rz\u00e9kel hangot", + "is_no_update": "{entity_name} naprak\u00e9sz", "is_no_vibration": "{entity_name} nem \u00e9rz\u00e9kel rezg\u00e9st", "is_not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", "is_not_cold": "{entity_name} nem hideg", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} f\u00fcst\u00f6t \u00e9rz\u00e9kel", "is_sound": "{entity_name} hangot \u00e9rz\u00e9kel", "is_unsafe": "{entity_name} nem biztons\u00e1gos", + "is_update": "{entity_name} egy friss\u00edt\u00e9s \u00e1ll rendelkez\u00e9sre", "is_vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} m\u00e1r nem \u00e9szlel probl\u00e9m\u00e1t", "no_smoke": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", "no_sound": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel hangot", + "no_update": "{entity_name} naprak\u00e9sz lett", "no_vibration": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel rezg\u00e9st", "not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", "not_cold": "{entity_name} m\u00e1r nem hideg", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} ki lett kapcsolva", "turned_on": "{entity_name} be lett kapcsolva", "unsafe": "{entity_name} m\u00e1r nem biztons\u00e1gos", + "update": "{entity_name} el\u00e9rhet\u0151 friss\u00edt\u00e9s", "vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" } }, @@ -178,6 +182,10 @@ "off": "Norm\u00e1l", "on": "\u00c9szlelve" }, + "update": { + "off": "Naprak\u00e9sz", + "on": "Friss\u00edt\u00e9s el\u00e9rhet\u0151" + }, "vibration": { "off": "Norm\u00e1l", "on": "\u00c9szlelve" diff --git a/homeassistant/components/binary_sensor/translations/it.json b/homeassistant/components/binary_sensor/translations/it.json index 68c427cbc04..b6301ed8f62 100644 --- a/homeassistant/components/binary_sensor/translations/it.json +++ b/homeassistant/components/binary_sensor/translations/it.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} non sta rilevando un problema", "is_no_smoke": "{entity_name} non sta rilevando il fumo", "is_no_sound": "{entity_name} non sta rilevando il suono", + "is_no_update": "{entity_name} \u00e8 aggiornato", "is_no_vibration": "{entity_name} non sta rilevando la vibrazione", "is_not_bat_low": "{entity_name} la batteria \u00e8 normale", "is_not_cold": "{entity_name} non \u00e8 freddo", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} sta rilevando il fumo", "is_sound": "{entity_name} sta rilevando il suono", "is_unsafe": "{entity_name} non \u00e8 sicuro", + "is_update": "{entity_name} ha un aggiornamento disponibile", "is_vibration": "{entity_name} sta rilevando la vibrazione" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} ha smesso di rilevare un problema", "no_smoke": "{entity_name} ha smesso la rilevazione di fumo", "no_sound": "{entity_name} ha smesso di rilevare il suono", + "no_update": "{entity_name} \u00e8 diventato aggiornato", "no_vibration": "{entity_name} ha smesso di rilevare le vibrazioni", "not_bat_low": "{entity_name} batteria normale", "not_cold": "{entity_name} non \u00e8 diventato freddo", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} disattivato", "turned_on": "{entity_name} attivato", "unsafe": "{entity_name} diventato non sicuro", + "update": "{entity_name} ha ottenuto un aggiornamento disponibile", "vibration": "{entity_name} iniziato a rilevare le vibrazioni" } }, @@ -178,6 +182,10 @@ "off": "Assente", "on": "Rilevato" }, + "update": { + "off": "Aggiornato", + "on": "Aggiornamento disponibile" + }, "vibration": { "off": "Assente", "on": "Rilevata" diff --git a/homeassistant/components/binary_sensor/translations/nl.json b/homeassistant/components/binary_sensor/translations/nl.json index 9352bfa8d47..f395335c627 100644 --- a/homeassistant/components/binary_sensor/translations/nl.json +++ b/homeassistant/components/binary_sensor/translations/nl.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} detecteert geen probleem", "is_no_smoke": "{entity_name} detecteert geen rook", "is_no_sound": "{entity_name} detecteert geen geluid", + "is_no_update": "{entity_name} is up-to-date", "is_no_vibration": "{entity_name} detecteert geen trillingen", "is_not_bat_low": "{entity_name} batterij is normaal", "is_not_cold": "{entity_name} is niet koud", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} detecteert rook", "is_sound": "{entity_name} detecteert geluid", "is_unsafe": "{entity_name} is onveilig", + "is_update": "{entity_name} heeft een update beschikbaar", "is_vibration": "{entity_name} detecteert trillingen" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} gestopt met het detecteren van het probleem", "no_smoke": "{entity_name} gestopt met het detecteren van rook", "no_sound": "{entity_name} gestopt met het detecteren van geluid", + "no_update": "{entity_name} werd ge\u00fcpdatet", "no_vibration": "{entity_name} gestopt met het detecteren van trillingen", "not_bat_low": "{entity_name} batterij normaal", "not_cold": "{entity_name} werd niet koud", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} uitgeschakeld", "turned_on": "{entity_name} ingeschakeld", "unsafe": "{entity_name} werd onveilig", + "update": "{entity_name} kreeg een update beschikbaar", "vibration": "{entity_name} begon trillingen te detecteren" } }, @@ -178,6 +182,10 @@ "off": "Niet gedetecteerd", "on": "Gedetecteerd" }, + "update": { + "off": "Up-to-date", + "on": "Update beschikbaar" + }, "vibration": { "off": "Niet gedetecteerd", "on": "Gedetecteerd" diff --git a/homeassistant/components/binary_sensor/translations/no.json b/homeassistant/components/binary_sensor/translations/no.json index 023fec6cc39..041643f9cc3 100644 --- a/homeassistant/components/binary_sensor/translations/no.json +++ b/homeassistant/components/binary_sensor/translations/no.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} registrerer ikke et problem", "is_no_smoke": "{entity_name} registrerer ikke r\u00f8yk", "is_no_sound": "{entity_name} registrerer ikke lyd", + "is_no_update": "{entity_name} er oppdatert", "is_no_vibration": "{entity_name} registrerer ikke bevegelse", "is_not_bat_low": "{entity_name} batteri er normalt", "is_not_cold": "{entity_name} er ikke kald", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} registrerer r\u00f8yk", "is_sound": "{entity_name} registrerer lyd", "is_unsafe": "{entity_name} er utrygg", + "is_update": "{entity_name} har en tilgjengelig oppdatering", "is_vibration": "{entity_name} registrerer vibrasjon" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} sluttet \u00e5 registrere problem", "no_smoke": "{entity_name} sluttet \u00e5 registrere r\u00f8yk", "no_sound": "{entity_name} sluttet \u00e5 registrere lyd", + "no_update": "{entity_name} ble oppdatert", "no_vibration": "{entity_name} sluttet \u00e5 registrere vibrasjon", "not_bat_low": "{entity_name} batteri normalt", "not_cold": "{entity_name} ble ikke lenger kald", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} sl\u00e5tt av", "turned_on": "{entity_name} sl\u00e5tt p\u00e5", "unsafe": "{entity_name} ble usikker", + "update": "{entity_name} har en oppdatering tilgjengelig", "vibration": "{entity_name} begynte \u00e5 oppdage vibrasjon" } }, @@ -178,6 +182,10 @@ "off": "Klart", "on": "Oppdaget" }, + "update": { + "off": "Oppdatert", + "on": "Oppdatering tilgjengelig" + }, "vibration": { "off": "Klart", "on": "Oppdaget" diff --git a/homeassistant/components/binary_sensor/translations/pl.json b/homeassistant/components/binary_sensor/translations/pl.json index 726765aea02..6e6b272d869 100644 --- a/homeassistant/components/binary_sensor/translations/pl.json +++ b/homeassistant/components/binary_sensor/translations/pl.json @@ -17,6 +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_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", @@ -42,6 +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_vibration": "sensor {entity_name} wykrywa wibracje" }, "trigger_type": { @@ -61,6 +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_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", @@ -86,6 +89,7 @@ "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}", "unsafe": "sensor {entity_name} wykryje zagro\u017cenie", + "update": "{entity_name} ma dost\u0119pn\u0105 aktualizacj\u0119", "vibration": "sensor {entity_name} wykryje wibracje" } }, @@ -178,6 +182,10 @@ "off": "brak", "on": "wykryto" }, + "update": { + "off": "Aktualny(-a)", + "on": "Dost\u0119pna aktualizacja" + }, "vibration": { "off": "brak", "on": "wykryto" diff --git a/homeassistant/components/binary_sensor/translations/ru.json b/homeassistant/components/binary_sensor/translations/ru.json index 2db1506b392..c245d2ba15a 100644 --- a/homeassistant/components/binary_sensor/translations/ru.json +++ b/homeassistant/components/binary_sensor/translations/ru.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", "is_no_smoke": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", "is_no_sound": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", + "is_no_update": "{entity_name} \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f", "is_no_vibration": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", "is_not_bat_low": "{entity_name} \u0432 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", "is_not_cold": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", "is_sound": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", "is_unsafe": "{entity_name} \u0432 \u043d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_update": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 {entity_name}", "is_vibration": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", "no_smoke": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u044b\u043c", "no_sound": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0437\u0432\u0443\u043a", + "no_update": "{entity_name} \u043e\u0431\u043d\u043e\u0432\u043b\u044f\u0435\u0442\u0441\u044f", "no_vibration": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", "not_bat_low": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439 \u0437\u0430\u0440\u044f\u0434", "not_cold": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u0442\u044c\u0441\u044f", @@ -86,6 +89,7 @@ "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", "unsafe": "{entity_name} \u043d\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", + "update": "\u0421\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 {entity_name}", "vibration": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" } }, @@ -178,6 +182,10 @@ "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d" }, + "update": { + "off": "\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f", + "on": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435" + }, "vibration": { "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430", "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430" diff --git a/homeassistant/components/binary_sensor/translations/zh-Hant.json b/homeassistant/components/binary_sensor/translations/zh-Hant.json index bf50782743e..4733d4d1dcc 100644 --- a/homeassistant/components/binary_sensor/translations/zh-Hant.json +++ b/homeassistant/components/binary_sensor/translations/zh-Hant.json @@ -17,6 +17,7 @@ "is_no_problem": "{entity_name}\u672a\u5075\u6e2c\u5230\u554f\u984c", "is_no_smoke": "{entity_name}\u672a\u5075\u6e2c\u5230\u7159\u9727", "is_no_sound": "{entity_name}\u672a\u5075\u6e2c\u5230\u8072\u97f3", + "is_no_update": "{entity_name} \u5df2\u6700\u65b0", "is_no_vibration": "{entity_name}\u672a\u5075\u6e2c\u5230\u9707\u52d5", "is_not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", "is_not_cold": "{entity_name}\u4e0d\u51b7", @@ -42,6 +43,7 @@ "is_smoke": "{entity_name}\u6b63\u5075\u6e2c\u5230\u7159\u9727", "is_sound": "{entity_name}\u6b63\u5075\u6e2c\u5230\u8072\u97f3", "is_unsafe": "{entity_name}\u4e0d\u5b89\u5168", + "is_update": "{entity_name} \u6709\u66f4\u65b0", "is_vibration": "{entity_name}\u6b63\u5075\u6e2c\u5230\u9707\u52d5" }, "trigger_type": { @@ -61,6 +63,7 @@ "no_problem": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u554f\u984c", "no_smoke": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u7159\u9727", "no_sound": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u8072\u97f3", + "no_update": "{entity_name} \u5df2\u6700\u65b0", "no_vibration": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u9707\u52d5", "not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", "not_cold": "{entity_name}\u5df2\u4e0d\u51b7", @@ -86,6 +89,7 @@ "turned_off": "{entity_name}\u5df2\u95dc\u9589", "turned_on": "{entity_name}\u5df2\u958b\u555f", "unsafe": "{entity_name}\u5df2\u4e0d\u5b89\u5168", + "update": "{entity_name} \u6709\u66f4\u65b0", "vibration": "{entity_name}\u5df2\u5075\u6e2c\u5230\u9707\u52d5" } }, @@ -178,6 +182,10 @@ "off": "\u672a\u89f8\u767c", "on": "\u5df2\u89f8\u767c" }, + "update": { + "off": "\u5df2\u6700\u65b0", + "on": "\u6709\u66f4\u65b0" + }, "vibration": { "off": "\u672a\u5075\u6e2c", "on": "\u5075\u6e2c" diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index d11c2a2b726..b66f775eae2 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -1,11 +1,17 @@ """Bitcoin information service that uses blockchain.com.""" +from __future__ import annotations + from datetime import timedelta import logging from blockchain import exchangerates, statistics 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_CURRENCY, @@ -25,34 +31,112 @@ ICON = "mdi:currency-btc" SCAN_INTERVAL = timedelta(minutes=5) -OPTION_TYPES = { - "exchangerate": ["Exchange rate (1 BTC)", None], - "trade_volume_btc": ["Trade volume", "BTC"], - "miners_revenue_usd": ["Miners revenue", "USD"], - "btc_mined": ["Mined", "BTC"], - "trade_volume_usd": ["Trade volume", "USD"], - "difficulty": ["Difficulty", None], - "minutes_between_blocks": ["Time between Blocks", TIME_MINUTES], - "number_of_transactions": ["No. of Transactions", None], - "hash_rate": ["Hash rate", f"PH/{TIME_SECONDS}"], - "timestamp": ["Timestamp", None], - "mined_blocks": ["Mined Blocks", None], - "blocks_size": ["Block size", None], - "total_fees_btc": ["Total fees", "BTC"], - "total_btc_sent": ["Total sent", "BTC"], - "estimated_btc_sent": ["Estimated sent", "BTC"], - "total_btc": ["Total", "BTC"], - "total_blocks": ["Total Blocks", None], - "next_retarget": ["Next retarget", None], - "estimated_transaction_volume_usd": ["Est. Transaction volume", "USD"], - "miners_revenue_btc": ["Miners revenue", "BTC"], - "market_price_usd": ["Market price", "USD"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="exchangerate", + name="Exchange rate (1 BTC)", + ), + SensorEntityDescription( + key="trade_volume_btc", + name="Trade volume", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="miners_revenue_usd", + name="Miners revenue", + native_unit_of_measurement="USD", + ), + SensorEntityDescription( + key="btc_mined", + name="Mined", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="trade_volume_usd", + name="Trade volume", + native_unit_of_measurement="USD", + ), + SensorEntityDescription( + key="difficulty", + name="Difficulty", + ), + SensorEntityDescription( + key="minutes_between_blocks", + name="Time between Blocks", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key="number_of_transactions", + name="No. of Transactions", + ), + SensorEntityDescription( + key="hash_rate", + name="Hash rate", + native_unit_of_measurement=f"PH/{TIME_SECONDS}", + ), + SensorEntityDescription( + key="timestamp", + name="Timestamp", + ), + SensorEntityDescription( + key="mined_blocks", + name="Mined Blocks", + ), + SensorEntityDescription( + key="blocks_size", + name="Block size", + ), + SensorEntityDescription( + key="total_fees_btc", + name="Total fees", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_btc_sent", + name="Total sent", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="estimated_btc_sent", + name="Estimated sent", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_btc", + name="Total", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="total_blocks", + name="Total Blocks", + ), + SensorEntityDescription( + key="next_retarget", + name="Next retarget", + ), + SensorEntityDescription( + key="estimated_transaction_volume_usd", + name="Est. Transaction volume", + native_unit_of_measurement="USD", + ), + SensorEntityDescription( + key="miners_revenue_btc", + name="Miners revenue", + native_unit_of_measurement="BTC", + ), + SensorEntityDescription( + key="market_price_usd", + name="Market price", + native_unit_of_measurement="USD", + ), +) + +OPTION_KEYS = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DISPLAY_OPTIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(OPTION_TYPES)] + cv.ensure_list, [vol.In(OPTION_KEYS)] ), vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, } @@ -69,11 +153,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): currency = DEFAULT_CURRENCY data = BitcoinData() - dev = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - dev.append(BitcoinSensor(data, variable, currency)) + entities = [ + BitcoinSensor(data, currency, description) + for description in SENSOR_TYPES + if description.key in config[CONF_DISPLAY_OPTIONS] + ] - add_entities(dev, True) + add_entities(entities, True) class BitcoinSensor(SensorEntity): @@ -82,13 +168,11 @@ class BitcoinSensor(SensorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON - def __init__(self, data, option_type, currency): + def __init__(self, data, currency, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._attr_name = OPTION_TYPES[option_type][0] - self._attr_unit_of_measurement = OPTION_TYPES[option_type][1] self._currency = currency - self.type = option_type def update(self): """Get the latest data and updates the states.""" @@ -96,49 +180,50 @@ class BitcoinSensor(SensorEntity): stats = self.data.stats ticker = self.data.ticker - if self.type == "exchangerate": - self._attr_state = ticker[self._currency].p15min - self._attr_unit_of_measurement = self._currency - elif self.type == "trade_volume_btc": - self._attr_state = f"{stats.trade_volume_btc:.1f}" - elif self.type == "miners_revenue_usd": - self._attr_state = f"{stats.miners_revenue_usd:.0f}" - elif self.type == "btc_mined": - self._attr_state = str(stats.btc_mined * 0.00000001) - elif self.type == "trade_volume_usd": - self._attr_state = f"{stats.trade_volume_usd:.1f}" - elif self.type == "difficulty": - self._attr_state = f"{stats.difficulty:.0f}" - elif self.type == "minutes_between_blocks": - self._attr_state = f"{stats.minutes_between_blocks:.2f}" - elif self.type == "number_of_transactions": - self._attr_state = str(stats.number_of_transactions) - elif self.type == "hash_rate": - self._attr_state = f"{stats.hash_rate * 0.000001:.1f}" - elif self.type == "timestamp": - self._attr_state = stats.timestamp - elif self.type == "mined_blocks": - self._attr_state = str(stats.mined_blocks) - elif self.type == "blocks_size": - self._attr_state = f"{stats.blocks_size:.1f}" - elif self.type == "total_fees_btc": - self._attr_state = f"{stats.total_fees_btc * 0.00000001:.2f}" - elif self.type == "total_btc_sent": - self._attr_state = f"{stats.total_btc_sent * 0.00000001:.2f}" - elif self.type == "estimated_btc_sent": - self._attr_state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" - elif self.type == "total_btc": - self._attr_state = f"{stats.total_btc * 0.00000001:.2f}" - elif self.type == "total_blocks": - self._attr_state = f"{stats.total_blocks:.0f}" - elif self.type == "next_retarget": - self._attr_state = f"{stats.next_retarget:.2f}" - elif self.type == "estimated_transaction_volume_usd": - self._attr_state = f"{stats.estimated_transaction_volume_usd:.2f}" - elif self.type == "miners_revenue_btc": - self._attr_state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" - elif self.type == "market_price_usd": - self._attr_state = f"{stats.market_price_usd:.2f}" + sensor_type = self.entity_description.key + if sensor_type == "exchangerate": + self._attr_native_value = ticker[self._currency].p15min + self._attr_native_unit_of_measurement = self._currency + elif sensor_type == "trade_volume_btc": + self._attr_native_value = f"{stats.trade_volume_btc:.1f}" + elif sensor_type == "miners_revenue_usd": + self._attr_native_value = f"{stats.miners_revenue_usd:.0f}" + elif sensor_type == "btc_mined": + self._attr_native_value = str(stats.btc_mined * 0.00000001) + elif sensor_type == "trade_volume_usd": + self._attr_native_value = f"{stats.trade_volume_usd:.1f}" + elif sensor_type == "difficulty": + self._attr_native_value = f"{stats.difficulty:.0f}" + elif sensor_type == "minutes_between_blocks": + self._attr_native_value = f"{stats.minutes_between_blocks:.2f}" + elif sensor_type == "number_of_transactions": + self._attr_native_value = str(stats.number_of_transactions) + elif sensor_type == "hash_rate": + self._attr_native_value = f"{stats.hash_rate * 0.000001:.1f}" + elif sensor_type == "timestamp": + self._attr_native_value = stats.timestamp + elif sensor_type == "mined_blocks": + self._attr_native_value = str(stats.mined_blocks) + elif sensor_type == "blocks_size": + self._attr_native_value = f"{stats.blocks_size:.1f}" + elif sensor_type == "total_fees_btc": + self._attr_native_value = f"{stats.total_fees_btc * 0.00000001:.2f}" + elif sensor_type == "total_btc_sent": + self._attr_native_value = f"{stats.total_btc_sent * 0.00000001:.2f}" + elif sensor_type == "estimated_btc_sent": + self._attr_native_value = f"{stats.estimated_btc_sent * 0.00000001:.2f}" + elif sensor_type == "total_btc": + self._attr_native_value = f"{stats.total_btc * 0.00000001:.2f}" + elif sensor_type == "total_blocks": + self._attr_native_value = f"{stats.total_blocks:.0f}" + elif sensor_type == "next_retarget": + self._attr_native_value = f"{stats.next_retarget:.2f}" + elif sensor_type == "estimated_transaction_volume_usd": + self._attr_native_value = f"{stats.estimated_transaction_volume_usd:.2f}" + elif sensor_type == "miners_revenue_btc": + self._attr_native_value = f"{stats.miners_revenue_btc * 0.00000001:.1f}" + elif sensor_type == "market_price_usd": + self._attr_native_value = f"{stats.market_price_usd:.2f}" class BitcoinData: diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index 16f247693af..f83751fb503 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -37,7 +37,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BizkaibusSensor(SensorEntity): """The class for handling the data.""" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__(self, data, name): """Initialize the sensor.""" @@ -48,7 +48,7 @@ class BizkaibusSensor(SensorEntity): """Get the latest data from the webservice.""" self.data.update() with suppress(TypeError): - self._attr_state = self.data.info[0][ATTR_DUE_IN] + self._attr_native_value = self.data.info[0][ATTR_DUE_IN] class Bizkaibus: diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index 09bfca88776..200661dcd1c 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -20,10 +20,10 @@ class BleBoxSensorEntity(BleBoxEntity, SensorEntity): def __init__(self, feature): """Initialize a BleBox sensor feature.""" super().__init__(feature) - self._attr_unit_of_measurement = BLEBOX_TO_UNIT_MAP[feature.unit] + self._attr_native_unit_of_measurement = BLEBOX_TO_UNIT_MAP[feature.unit] self._attr_device_class = BLEBOX_TO_HASS_DEVICE_CLASSES[feature.device_class] @property - def state(self): + def native_value(self): """Return the state.""" return self._feature.current diff --git a/homeassistant/components/blebox/translations/ca.json b/homeassistant/components/blebox/translations/ca.json index d2b25c7590a..96a3a9f37ad 100644 --- a/homeassistant/components/blebox/translations/ca.json +++ b/homeassistant/components/blebox/translations/ca.json @@ -16,7 +16,7 @@ "host": "Adre\u00e7a IP", "port": "Port" }, - "description": "Configura el teu dispositiu BleBox per a integrar-lo a Home Assistant.", + "description": "Configura la integraci\u00f3 d'un dispositiu BleBox amb Home Assistant.", "title": "Configuraci\u00f3 del dispositiu BleBox" } } diff --git a/homeassistant/components/blebox/translations/es-419.json b/homeassistant/components/blebox/translations/es-419.json index eb0545e4fa4..89bafe049f2 100644 --- a/homeassistant/components/blebox/translations/es-419.json +++ b/homeassistant/components/blebox/translations/es-419.json @@ -16,7 +16,8 @@ "host": "Direcci\u00f3n IP", "port": "Puerto" }, - "description": "Configure su BleBox para integrarse con Home Assistant." + "description": "Configure su BleBox para integrarse con Home Assistant.", + "title": "Configure su dispositivo BleBox" } } } diff --git a/homeassistant/components/blebox/translations/hu.json b/homeassistant/components/blebox/translations/hu.json index 97a6c1bdc18..ce51a8a0967 100644 --- a/homeassistant/components/blebox/translations/hu.json +++ b/homeassistant/components/blebox/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "address_already_configured": "Egy BleBox-eszk\u00f6z m\u00e1r konfigur\u00e1lva van a(z) {address} c\u00edmen.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { @@ -14,7 +15,9 @@ "data": { "host": "IP c\u00edm", "port": "Port" - } + }, + "description": "\u00c1ll\u00edtsa be a BleBox k\u00e9sz\u00fcl\u00e9ket a Homeassistantba val\u00f3 integr\u00e1ci\u00f3hoz.", + "title": "\u00c1ll\u00edtsa be a BleBox eszk\u00f6zt" } } } diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index f9b8ec31605..6be284e2197 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -1,47 +1,60 @@ """Support for Blink system camera control.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_MOTION, BinarySensorEntity, + BinarySensorEntityDescription, ) from .const import DOMAIN, TYPE_BATTERY, TYPE_CAMERA_ARMED, TYPE_MOTION_DETECTED -BINARY_SENSORS = { - TYPE_BATTERY: ["Battery", DEVICE_CLASS_BATTERY], - TYPE_CAMERA_ARMED: ["Camera Armed", None], - TYPE_MOTION_DETECTED: ["Motion Detected", DEVICE_CLASS_MOTION], -} +BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key=TYPE_BATTERY, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + ), + BinarySensorEntityDescription( + key=TYPE_CAMERA_ARMED, + name="Camera Armed", + ), + BinarySensorEntityDescription( + key=TYPE_MOTION_DETECTED, + name="Motion Detected", + device_class=DEVICE_CLASS_MOTION, + ), +) async def async_setup_entry(hass, config, async_add_entities): """Set up the blink binary sensors.""" data = hass.data[DOMAIN][config.entry_id] - entities = [] - for camera in data.cameras: - for sensor_type in BINARY_SENSORS: - entities.append(BlinkBinarySensor(data, camera, sensor_type)) + entities = [ + BlinkBinarySensor(data, camera, description) + for camera in data.cameras + for description in BINARY_SENSORS_TYPES + ] async_add_entities(entities) class BlinkBinarySensor(BinarySensorEntity): """Representation of a Blink binary sensor.""" - def __init__(self, data, camera, sensor_type): + def __init__(self, data, camera, description: BinarySensorEntityDescription): """Initialize the sensor.""" self.data = data - self._type = sensor_type - name, device_class = BINARY_SENSORS[sensor_type] - self._attr_name = f"{DOMAIN} {camera} {name}" - self._attr_device_class = device_class + self.entity_description = description + self._attr_name = f"{DOMAIN} {camera} {description.name}" self._camera = data.cameras[camera] - self._attr_unique_id = f"{self._camera.serial}-{sensor_type}" + self._attr_unique_id = f"{self._camera.serial}-{description.key}" def update(self): """Update sensor state.""" self.data.refresh() - state = self._camera.attributes[self._type] - if self._type == TYPE_BATTERY: + state = self._camera.attributes[self.entity_description.key] + if self.entity_description.key == TYPE_BATTERY: state = state != "ok" self._attr_is_on = state diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index e2216dc8785..8b4f1ba4eec 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,4 +1,6 @@ """Support for Blink system camera.""" +from __future__ import annotations + import logging from homeassistant.components.camera import Camera @@ -65,6 +67,8 @@ class BlinkCamera(Camera): self._camera.snap_picture() self.data.refresh() - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return self._camera.image_from_cache.content diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 1f7cad3f872..d2122b59cd8 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -1,7 +1,9 @@ """Support for Blink system camera sensors.""" +from __future__ import annotations + import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, @@ -13,23 +15,30 @@ from .const import DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH _LOGGER = logging.getLogger(__name__) -SENSORS = { - TYPE_TEMPERATURE: ["Temperature", TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE], - TYPE_WIFI_STRENGTH: [ - "Wifi Signal", - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - DEVICE_CLASS_SIGNAL_STRENGTH, - ], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=TYPE_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=TYPE_WIFI_STRENGTH, + name="Wifi Signal", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), +) async def async_setup_entry(hass, config, async_add_entities): """Initialize a Blink sensor.""" data = hass.data[DOMAIN][config.entry_id] - entities = [] - for camera in data.cameras: - for sensor_type in SENSORS: - entities.append(BlinkSensor(data, camera, sensor_type)) + entities = [ + BlinkSensor(data, camera, description) + for camera in data.cameras + for description in SENSOR_TYPES + ] async_add_entities(entities) @@ -37,26 +46,26 @@ async def async_setup_entry(hass, config, async_add_entities): class BlinkSensor(SensorEntity): """A Blink camera sensor.""" - def __init__(self, data, camera, sensor_type): + def __init__(self, data, camera, description: SensorEntityDescription): """Initialize sensors from Blink camera.""" - name, units, device_class = SENSORS[sensor_type] - self._attr_name = f"{DOMAIN} {camera} {name}" - self._attr_device_class = device_class + self.entity_description = description + self._attr_name = f"{DOMAIN} {camera} {description.name}" self.data = data self._camera = data.cameras[camera] - self._attr_unit_of_measurement = units - self._attr_unique_id = f"{self._camera.serial}-{sensor_type}" + self._attr_unique_id = f"{self._camera.serial}-{description.key}" self._sensor_key = ( - "temperature_calibrated" if sensor_type == "temperature" else sensor_type + "temperature_calibrated" + if description.key == "temperature" + else description.key ) def update(self): """Retrieve sensor data from the camera.""" self.data.refresh() try: - self._attr_state = self._camera.attributes[self._sensor_key] + self._attr_native_value = self._camera.attributes[self._sensor_key] except KeyError: - self._attr_state = None + self._attr_native_value = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", self._sensor_key ) diff --git a/homeassistant/components/blink/translations/es-419.json b/homeassistant/components/blink/translations/es-419.json new file mode 100644 index 00000000000..d44527dd7ca --- /dev/null +++ b/homeassistant/components/blink/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "2fa": { + "data": { + "2fa": "C\u00f3digo de dos factores" + }, + "description": "Ingrese el PIN enviado a su correo electr\u00f3nico", + "title": "Autenticaci\u00f3n de dos factores" + }, + "user": { + "title": "Iniciar sesi\u00f3n con cuenta Blink" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "Intervalo de escaneo (segundos)" + }, + "description": "Configurar la integraci\u00f3n de Blink", + "title": "Opciones de Blink" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blink/translations/hu.json b/homeassistant/components/blink/translations/hu.json index e56b142a5b0..135a2f7ef2e 100644 --- a/homeassistant/components/blink/translations/hu.json +++ b/homeassistant/components/blink/translations/hu.json @@ -21,7 +21,19 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Jelentkezzen be Blink-fi\u00f3kkal" + } + } + }, + "options": { + "step": { + "simple_options": { + "data": { + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)" + }, + "description": "Blink integr\u00e1ci\u00f3 konfigur\u00e1l\u00e1sa", + "title": "Villog\u00e1si lehet\u0151s\u00e9gek" } } } diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index bbb9c892871..9d31d4c0583 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -48,7 +48,7 @@ class BlockchainSensor(SensorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON - _attr_unit_of_measurement = "BTC" + _attr_native_unit_of_measurement = "BTC" def __init__(self, name, addresses): """Initialize the sensor.""" @@ -57,4 +57,4 @@ class BlockchainSensor(SensorEntity): def update(self): """Get the latest state of the sensor.""" - self._attr_state = get_balance(self.addresses) + self._attr_native_value = get_balance(self.addresses) diff --git a/homeassistant/components/bloomsky/camera.py b/homeassistant/components/bloomsky/camera.py index 570842b9c66..a7255a74d4c 100644 --- a/homeassistant/components/bloomsky/camera.py +++ b/homeassistant/components/bloomsky/camera.py @@ -1,4 +1,6 @@ """Support for a camera of a BloomSky weather station.""" +from __future__ import annotations + import logging import requests @@ -37,7 +39,9 @@ class BloomSkyCamera(Camera): self._logger = logging.getLogger(__name__) self._attr_unique_id = self._id - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Update the camera's image if it has changed.""" try: self._url = self._bloomsky.devices[self._id]["Data"]["ImageURL"] diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 7aa2fe9baba..288a1767c7e 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -86,9 +86,13 @@ class BloomSkySensor(SensorEntity): self._sensor_name = sensor_name self._attr_name = f"{device['DeviceName']} {sensor_name}" self._attr_unique_id = f"{self._device_id}-{sensor_name}" - self._attr_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get(sensor_name, None) + self._attr_native_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get( + sensor_name, None + ) if self._bloomsky.is_metric: - self._attr_unit_of_measurement = SENSOR_UNITS_METRIC.get(sensor_name, None) + self._attr_native_unit_of_measurement = SENSOR_UNITS_METRIC.get( + sensor_name, None + ) @property def device_class(self): @@ -99,6 +103,6 @@ class BloomSkySensor(SensorEntity): """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] - self._attr_state = ( + self._attr_native_value = ( f"{state:.2f}" if self._sensor_name in FORMAT_NUMBERS else state ) diff --git a/homeassistant/components/bme280/__init__.py b/homeassistant/components/bme280/__init__.py index 8de2b2ffe8b..eca9ac85bc9 100644 --- a/homeassistant/components/bme280/__init__.py +++ b/homeassistant/components/bme280/__init__.py @@ -30,7 +30,7 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_T_STANDBY, DOMAIN, - SENSOR_TYPES, + SENSOR_KEYS, ) CONFIG_SCHEMA = vol.Schema( @@ -54,7 +54,7 @@ CONFIG_SCHEMA = vol.Schema( ): vol.Coerce(float), vol.Optional( CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED - ): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + ): vol.All(cv.ensure_list, [vol.In(SENSOR_KEYS)]), vol.Optional( CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP ): vol.Coerce(int), diff --git a/homeassistant/components/bme280/const.py b/homeassistant/components/bme280/const.py index 19dee41c855..e217c0df29e 100644 --- a/homeassistant/components/bme280/const.py +++ b/homeassistant/components/bme280/const.py @@ -1,11 +1,15 @@ """Constants for the BME280 component.""" +from __future__ import annotations + from datetime import timedelta +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, + TEMP_CELSIUS, ) # Common @@ -25,11 +29,27 @@ DEFAULT_SCAN_INTERVAL = 300 SENSOR_TEMP = "temperature" SENSOR_HUMID = "humidity" SENSOR_PRESS = "pressure" -SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], - SENSOR_HUMID: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], - SENSOR_PRESS: ["Pressure", "mb", DEVICE_CLASS_PRESSURE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMID, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=SENSOR_PRESS, + name="Pressure", + native_unit_of_measurement="mb", + device_class=DEVICE_CLASS_PRESSURE, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) # SPI diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py index 60ce963bf9e..f49d8959076 100644 --- a/homeassistant/components/bme280/sensor.py +++ b/homeassistant/components/bme280/sensor.py @@ -6,19 +6,17 @@ from bme280spi import BME280 as BME280_spi # pylint: disable=import-error from i2csense.bme280 import BME280 as BME280_i2c # pylint: disable=import-error import smbus -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_SCAN_INTERVAL, - TEMP_FAHRENHEIT, +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorEntity, + SensorEntityDescription, ) +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) -from homeassistant.util.temperature import celsius_to_fahrenheit from .const import ( CONF_DELTA_TEMP, @@ -47,7 +45,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the BME280 sensor.""" if discovery_info is None: return - SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit sensor_conf = discovery_info[SENSOR_DOMAIN] name = sensor_conf[CONF_NAME] scan_interval = max(sensor_conf[CONF_SCAN_INTERVAL], MIN_TIME_BETWEEN_UPDATES) @@ -105,42 +102,34 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= update_interval=scan_interval, ) await coordinator.async_refresh() - entities = [] - for condition in sensor_conf[CONF_MONITORED_CONDITIONS]: - entities.append( - BME280Sensor( - condition, - SENSOR_TYPES[condition][1], - name, - coordinator, - ) - ) + monitored_conditions = sensor_conf[CONF_MONITORED_CONDITIONS] + entities = [ + BME280Sensor(name, coordinator, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] async_add_entities(entities, True) class BME280Sensor(CoordinatorEntity, SensorEntity): """Implementation of the BME280 sensor.""" - def __init__(self, sensor_type, temp_unit, name, coordinator): + def __init__(self, name, coordinator, description: SensorEntityDescription): """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self.temp_unit = temp_unit - self.type = sensor_type - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - if self.type == SENSOR_TEMP: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMP: temperature = round(self.coordinator.data.temperature, 1) - if self.temp_unit == TEMP_FAHRENHEIT: - temperature = round(celsius_to_fahrenheit(temperature), 1) state = temperature - elif self.type == SENSOR_HUMID: + elif sensor_type == SENSOR_HUMID: state = round(self.coordinator.data.humidity, 1) - elif self.type == SENSOR_PRESS: + elif sensor_type == SENSOR_PRESS: state = round(self.coordinator.data.pressure, 1) return state diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index 527a971b237..6c32639fdb4 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -1,4 +1,6 @@ """Support for BME680 Sensor over SMBus.""" +from __future__ import annotations + import logging import threading from time import monotonic, sleep @@ -7,7 +9,11 @@ import bme680 # pylint: disable=import-error from smbus import SMBus 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, @@ -15,10 +21,9 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -55,13 +60,37 @@ SENSOR_HUMID = "humidity" SENSOR_PRESS = "pressure" SENSOR_GAS = "gas" SENSOR_AQ = "airquality" -SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], - SENSOR_HUMID: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], - SENSOR_PRESS: ["Pressure", "mb", DEVICE_CLASS_PRESSURE], - SENSOR_GAS: ["Gas Resistance", "Ohms", None], - SENSOR_AQ: ["Air Quality", PERCENTAGE, None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMP, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMID, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + SensorEntityDescription( + key=SENSOR_PRESS, + name="Pressure", + native_unit_of_measurement="mb", + device_class=DEVICE_CLASS_PRESSURE, + ), + SensorEntityDescription( + key=SENSOR_GAS, + name="Gas Resistance", + native_unit_of_measurement="Ohms", + ), + SensorEntityDescription( + key=SENSOR_AQ, + name="Air Quality", + native_unit_of_measurement=PERCENTAGE, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ] OVERSAMPLING_VALUES = {0, 1, 2, 4, 8, 16} FILTER_VALUES = {0, 1, 3, 7, 15, 31, 63, 127} @@ -71,7 +100,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.positive_int, vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): cv.positive_int, vol.Optional( @@ -110,21 +139,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the BME680 sensor.""" - SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit name = config[CONF_NAME] sensor_handler = await hass.async_add_executor_job(_setup_bme680, config) if sensor_handler is None: return - dev = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - BME680Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) - ) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + BME680Sensor(sensor_handler, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - async_add_entities(dev) - return + async_add_entities(entities) def _setup_bme680(config): @@ -321,31 +349,29 @@ class BME680Handler: class BME680Sensor(SensorEntity): """Implementation of the BME680 sensor.""" - def __init__(self, bme680_client, sensor_type, temp_unit, name): + def __init__(self, bme680_client, name, description: SensorEntityDescription): """Initialize the sensor.""" - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.bme680_client = bme680_client - self.temp_unit = temp_unit - self.type = sensor_type - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] async def async_update(self): """Get the latest data from the BME680 and update the states.""" await self.hass.async_add_executor_job(self.bme680_client.update) - if self.type == SENSOR_TEMP: - self._attr_state = round(self.bme680_client.sensor_data.temperature, 1) - if self.temp_unit == TEMP_FAHRENHEIT: - self._attr_state = round(celsius_to_fahrenheit(self.state), 1) - elif self.type == SENSOR_HUMID: - self._attr_state = round(self.bme680_client.sensor_data.humidity, 1) - elif self.type == SENSOR_PRESS: - self._attr_state = round(self.bme680_client.sensor_data.pressure, 1) - elif self.type == SENSOR_GAS: - self._attr_state = int( + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMP: + self._attr_native_value = round( + self.bme680_client.sensor_data.temperature, 1 + ) + elif sensor_type == SENSOR_HUMID: + self._attr_native_value = round(self.bme680_client.sensor_data.humidity, 1) + elif sensor_type == SENSOR_PRESS: + self._attr_native_value = round(self.bme680_client.sensor_data.pressure, 1) + elif sensor_type == SENSOR_GAS: + self._attr_native_value = int( round(self.bme680_client.sensor_data.gas_resistance, 0) ) - elif self.type == SENSOR_AQ: + elif sensor_type == SENSOR_AQ: aq_score = self.bme680_client.sensor_data.air_quality if aq_score is not None: - self._attr_state = round(aq_score, 1) + self._attr_native_value = round(aq_score, 1) diff --git a/homeassistant/components/bmp280/sensor.py b/homeassistant/components/bmp280/sensor.py index 7bf355bb736..21ab71e5ce6 100644 --- a/homeassistant/components/bmp280/sensor.py +++ b/homeassistant/components/bmp280/sensor.py @@ -78,7 +78,7 @@ class Bmp280Sensor(SensorEntity): """Initialize the sensor.""" self._bmp280 = bmp280 self._attr_name = name - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement class Bmp280TemperatureSensor(Bmp280Sensor): @@ -94,7 +94,7 @@ class Bmp280TemperatureSensor(Bmp280Sensor): def update(self): """Fetch new state data for the sensor.""" try: - self._attr_state = round(self._bmp280.temperature, 1) + self._attr_native_value = round(self._bmp280.temperature, 1) if not self.available: _LOGGER.warning("Communication restored with temperature sensor") self._attr_available = True @@ -119,7 +119,7 @@ class Bmp280PressureSensor(Bmp280Sensor): def update(self): """Fetch new state data for the sensor.""" try: - self._attr_state = round(self._bmp280.pressure) + self._attr_native_value = round(self._bmp280.pressure) if not self.available: _LOGGER.warning("Communication restored with pressure sensor") self._attr_available = True diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 17e57b5d09c..85a5c9cd02f 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers import device_registry, discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_utc_time_change +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -79,7 +80,7 @@ _SERVICE_MAP = { UNDO_UPDATE_LISTENER = "undo_update_listener" -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the BMW Connected Drive component from configuration.yaml.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][DATA_HASS_CONFIG] = config diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index a7c4c5c837b..8a1e7e2c826 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.19"], + "requirements": ["bimmer_connected==0.7.20"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index df899496339..76d183bf8e8 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -516,7 +516,7 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): self._attr_device_class = attribute_info.get( attribute, [None, None, None, None] )[1] - self._attr_unit_of_measurement = attribute_info.get( + self._attr_native_unit_of_measurement = attribute_info.get( attribute, [None, None, None, None] )[2] @@ -525,24 +525,24 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): _LOGGER.debug("Updating %s", self._vehicle.name) vehicle_state = self._vehicle.state if self._attribute == "charging_status": - self._attr_state = getattr(vehicle_state, self._attribute).value + self._attr_native_value = getattr(vehicle_state, self._attribute).value elif self.unit_of_measurement == VOLUME_GALLONS: value = getattr(vehicle_state, self._attribute) value_converted = self.hass.config.units.volume(value, VOLUME_LITERS) - self._attr_state = round(value_converted) + self._attr_native_value = round(value_converted) elif self.unit_of_measurement == LENGTH_MILES: value = getattr(vehicle_state, self._attribute) value_converted = self.hass.config.units.length(value, LENGTH_KILOMETERS) - self._attr_state = round(value_converted) + self._attr_native_value = round(value_converted) elif self._service is None: - self._attr_state = getattr(vehicle_state, self._attribute) + self._attr_native_value = getattr(vehicle_state, self._attribute) elif self._service == SERVICE_LAST_TRIP: vehicle_last_trip = self._vehicle.state.last_trip if self._attribute == "date_utc": date_str = getattr(vehicle_last_trip, "date") - self._attr_state = dt_util.parse_datetime(date_str).isoformat() + self._attr_native_value = dt_util.parse_datetime(date_str).isoformat() else: - self._attr_state = getattr(vehicle_last_trip, self._attribute) + self._attr_native_value = getattr(vehicle_last_trip, self._attribute) elif self._service == SERVICE_ALL_TRIPS: vehicle_all_trips = self._vehicle.state.all_trips for attribute in ( @@ -555,13 +555,13 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): if self._attribute.startswith(f"{attribute}_"): attr = getattr(vehicle_all_trips, attribute) sub_attr = self._attribute.replace(f"{attribute}_", "") - self._attr_state = getattr(attr, sub_attr) + self._attr_native_value = getattr(attr, sub_attr) return if self._attribute == "reset_date_utc": date_str = getattr(vehicle_all_trips, "reset_date") - self._attr_state = dt_util.parse_datetime(date_str).isoformat() + self._attr_native_value = dt_util.parse_datetime(date_str).isoformat() else: - self._attr_state = getattr(vehicle_all_trips, self._attribute) + self._attr_native_value = getattr(vehicle_all_trips, self._attribute) vehicle_state = self._vehicle.state charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] diff --git a/homeassistant/components/bmw_connected_drive/translations/es-419.json b/homeassistant/components/bmw_connected_drive/translations/es-419.json new file mode 100644 index 00000000000..0bce46abd97 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/translations/es-419.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "region": "Regi\u00f3n de ConnectedDrive" + } + } + } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Solo lectura (solo sensores y notificaci\u00f3n, sin ejecuci\u00f3n de servicios, sin bloqueo)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 31eceda6c41..9fe33e8e99e 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -12,7 +12,9 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,6 +25,16 @@ from .utils import BondDevice _LOGGER = logging.getLogger(__name__) +SERVICE_START_INCREASING_BRIGHTNESS = "start_increasing_brightness" +SERVICE_START_DECREASING_BRIGHTNESS = "start_decreasing_brightness" +SERVICE_STOP = "stop" + +ENTITY_SERVICES = [ + SERVICE_START_INCREASING_BRIGHTNESS, + SERVICE_START_DECREASING_BRIGHTNESS, + SERVICE_STOP, +] + async def async_setup_entry( hass: HomeAssistant, @@ -34,6 +46,14 @@ async def async_setup_entry( hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + platform = entity_platform.async_get_current_platform() + for service in ENTITY_SERVICES: + platform.async_register_entity_service( + service, + {}, + f"async_{service}", + ) + fan_lights: list[Entity] = [ BondLight(hub, device, bpup_subs) for device in hub.devices @@ -119,6 +139,31 @@ class BondLight(BondBaseLight, BondEntity, LightEntity): """Turn off the light.""" await self._hub.bond.action(self._device.device_id, Action.turn_light_off()) + @callback + def _async_has_action_or_raise(self, action: str) -> None: + """Raise HomeAssistantError if the device does not support an action.""" + if not self._device.has_action(action): + raise HomeAssistantError(f"{self.entity_id} does not support {action}") + + async def async_start_increasing_brightness(self) -> None: + """Start increasing the light brightness.""" + self._async_has_action_or_raise(Action.START_INCREASING_BRIGHTNESS) + await self._hub.bond.action( + self._device.device_id, Action(Action.START_INCREASING_BRIGHTNESS) + ) + + async def async_start_decreasing_brightness(self) -> None: + """Start decreasing the light brightness.""" + self._async_has_action_or_raise(Action.START_DECREASING_BRIGHTNESS) + await self._hub.bond.action( + self._device.device_id, Action(Action.START_DECREASING_BRIGHTNESS) + ) + + async def async_stop(self) -> None: + """Stop all actions and clear the queue.""" + self._async_has_action_or_raise(Action.STOP) + await self._hub.bond.action(self._device.device_id, Action(Action.STOP)) + class BondDownLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" diff --git a/homeassistant/components/bond/services.yaml b/homeassistant/components/bond/services.yaml new file mode 100644 index 00000000000..1cb24c5ed71 --- /dev/null +++ b/homeassistant/components/bond/services.yaml @@ -0,0 +1,23 @@ +start_increasing_brightness: + name: Start increasing brightness + description: "Start increasing the brightness of the light." + target: + entity: + integration: bond + domain: light + +start_decreasing_brightness: + name: Start decreasing brightness + description: "Start decreasing the brightness of the light." + target: + entity: + integration: bond + domain: light + +stop: + name: Stop + description: "Stop any in-progress action and empty the queue." + target: + entity: + integration: bond + domain: light diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 6ace83831fe..4f3de1bf1f0 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -25,7 +25,8 @@ class BondDevice: """Create a helper device from ID and attributes returned by API.""" self.device_id = device_id self.props = props - self._attrs = attrs + self._attrs = attrs or {} + self._supported_actions: set[str] = set(self._attrs.get("actions", [])) def __repr__(self) -> str: """Return readable representation of a bond device.""" @@ -65,13 +66,13 @@ class BondDevice: """Check if Trust State is turned on.""" return self.props.get("trust_state", False) + def has_action(self, action: str) -> bool: + """Check to see if the device supports an actions.""" + return action in self._supported_actions + def _has_any_action(self, actions: set[str]) -> bool: """Check to see if the device supports any of the actions.""" - supported_actions: list[str] = self._attrs["actions"] - for action in supported_actions: - if action in actions: - return True - return False + return bool(self._supported_actions.intersection(actions)) def supports_speed(self) -> bool: """Return True if this device supports any of the speed related commands.""" diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 55aa1eb5772..6ea4f3c7065 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -147,7 +147,7 @@ class TemperatureSensor(SHCEntity, SensorEntity): """Representation of an SHC temperature reporting sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC temperature reporting sensor.""" @@ -156,7 +156,7 @@ class TemperatureSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_temperature" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.temperature @@ -165,7 +165,7 @@ class HumiditySensor(SHCEntity, SensorEntity): """Representation of an SHC humidity reporting sensor.""" _attr_device_class = DEVICE_CLASS_HUMIDITY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC humidity reporting sensor.""" @@ -174,7 +174,7 @@ class HumiditySensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_humidity" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.humidity @@ -183,7 +183,7 @@ class PuritySensor(SHCEntity, SensorEntity): """Representation of an SHC purity reporting sensor.""" _attr_icon = "mdi:molecule-co2" - _attr_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC purity reporting sensor.""" @@ -192,7 +192,7 @@ class PuritySensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_purity" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.purity @@ -207,7 +207,7 @@ class AirQualitySensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_airquality" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.combined_rating.name @@ -229,7 +229,7 @@ class TemperatureRatingSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_temperature_rating" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.temperature_rating.name @@ -244,7 +244,7 @@ class HumidityRatingSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_humidity_rating" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.humidity_rating.name @@ -259,7 +259,7 @@ class PurityRatingSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_purity_rating" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.purity_rating.name @@ -268,7 +268,7 @@ class PowerSensor(SHCEntity, SensorEntity): """Representation of an SHC power reporting sensor.""" _attr_device_class = DEVICE_CLASS_POWER - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC power reporting sensor.""" @@ -277,7 +277,7 @@ class PowerSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_power" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.powerconsumption @@ -286,7 +286,7 @@ class EnergySensor(SHCEntity, SensorEntity): """Representation of an SHC energy reporting sensor.""" _attr_device_class = DEVICE_CLASS_ENERGY - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC energy reporting sensor.""" @@ -295,7 +295,7 @@ class EnergySensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{self._device.serial}_energy" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.energyconsumption / 1000.0 @@ -304,7 +304,7 @@ class ValveTappetSensor(SHCEntity, SensorEntity): """Representation of an SHC valve tappet reporting sensor.""" _attr_icon = "mdi:gauge" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC valve tappet reporting sensor.""" @@ -313,7 +313,7 @@ class ValveTappetSensor(SHCEntity, SensorEntity): self._attr_unique_id = f"{device.serial}_valvetappet" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.position diff --git a/homeassistant/components/bosch_shc/translations/es-419.json b/homeassistant/components/bosch_shc/translations/es-419.json new file mode 100644 index 00000000000..fdb8903a318 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "confirm_discovery": { + "description": "Presione el bot\u00f3n frontal del Controlador de Hogar Inteligente de Bosch hasta que el LED comience a parpadear.\n\u00bfListo para continuar configurando {model} @ {host} con Home Assistant?" + }, + "credentials": { + "data": { + "password": "Contrase\u00f1a del controlador Smart Home" + } + }, + "reauth_confirm": { + "description": "La integraci\u00f3n bosch_shc necesita volver a autenticar su cuenta" + }, + "user": { + "description": "Configure su controlador de hogar inteligente de Bosch para permitir la supervisi\u00f3n y el control con Home Assistant.", + "title": "Par\u00e1metros de autenticaci\u00f3n SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/zh-Hans.json b/homeassistant/components/bosch_shc/translations/zh-Hans.json new file mode 100644 index 00000000000..46682f56114 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "pairing_failed": "\u914d\u5bf9\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u535a\u4e16 Smart Home Controller \u662f\u5426\u6b63\u5728\u5904\u4e8e\u914d\u5bf9\u6a21\u5f0f(LED \u706f\u95ea\u70c1)\uff0c\u4ee5\u53ca\u952e\u5165\u7684\u5bc6\u7801\u662f\u5426\u6b63\u786e" + }, + "step": { + "credentials": { + "data": { + "password": "Smart Home Controller \u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 159f3806d61..8e59033ffc8 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -157,7 +157,7 @@ class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow): content_mapping = await self.hass.async_add_executor_job( braviarc.load_source_list ) - self.source_list = {item: item for item in [*content_mapping]} + self.source_list = {item: item for item in content_mapping} return await self.async_step_user() async def async_step_user( diff --git a/homeassistant/components/braviatv/translations/en_GB.json b/homeassistant/components/braviatv/translations/en_GB.json new file mode 100644 index 00000000000..af063f30a87 --- /dev/null +++ b/homeassistant/components/braviatv/translations/en_GB.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "authorize": { + "title": "Authorise Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/es-419.json b/homeassistant/components/braviatv/translations/es-419.json index 6a2a0da982e..319eff13b98 100644 --- a/homeassistant/components/braviatv/translations/es-419.json +++ b/homeassistant/components/braviatv/translations/es-419.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Esta televisi\u00f3n ya est\u00e1 configurada." + "already_configured": "Esta televisi\u00f3n ya est\u00e1 configurada.", + "no_ip_control": "El control de IP est\u00e1 desactivado en su televisor o el televisor no es compatible." }, "error": { "cannot_connect": "No se pudo conectar, host inv\u00e1lido o c\u00f3digo PIN.", diff --git a/homeassistant/components/braviatv/translations/hu.json b/homeassistant/components/braviatv/translations/hu.json index fbb23fdee04..5f96af8bad7 100644 --- a/homeassistant/components/braviatv/translations/hu.json +++ b/homeassistant/components/braviatv/translations/hu.json @@ -14,12 +14,14 @@ "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.", "title": "Sony Bravia TV enged\u00e9lyez\u00e9se" }, "user": { "data": { "host": "Hoszt" }, + "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/braviatv/translations/zh-Hans.json b/homeassistant/components/braviatv/translations/zh-Hans.json index c839a271614..d02d562d55d 100644 --- a/homeassistant/components/braviatv/translations/zh-Hans.json +++ b/homeassistant/components/braviatv/translations/zh-Hans.json @@ -4,10 +4,20 @@ "authorize": { "data": { "pin": "PIN \u7801" - } + }, + "description": "\u8f93\u5165\u5728 Sony Bravia \u7535\u89c6\u4e0a\u663e\u793a\u7684 PIN \u7801\u3002 \n\n\u5982\u679c\u672a\u663e\u793a PIN \u7801\uff0c\u60a8\u9700\u8981\u5728\u7535\u89c6\u4e0a\u53d6\u6d88\u6ce8\u518c Home Assistant\uff0c\u8bf7\u8f6c\u5230\uff1a\u8bbe\u7f6e - >\u7f51\u7edc - >\u8fdc\u7a0b\u8bbe\u5907\u8bbe\u7f6e - >\u53d6\u6d88\u6ce8\u518c\u8fdc\u7a0b\u8bbe\u5907\u3002", + "title": "\u6388\u6743 Sony Bravia \u7535\u89c6" }, "user": { - "description": "\u8bbe\u7f6eSony Bravia\u7535\u89c6\u96c6\u6210\u3002\u5982\u679c\u60a8\u5728\u914d\u7f6e\u65b9\u9762\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/braviatv\n\u786e\u4fdd\u7535\u89c6\u5df2\u6253\u5f00\u3002" + "description": "\u8bbe\u7f6e Sony Bravia \u7535\u89c6\u96c6\u6210\u3002\u5982\u679c\u60a8\u5728\u914d\u7f6e\u65b9\u9762\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/braviatv\n\u786e\u4fdd\u7535\u89c6\u5df2\u6253\u5f00\u3002", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "title": "Sony Bravia \u7535\u89c6\u9009\u9879" } } } diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 30bc8047d03..676edb53b9a 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -1,4 +1,6 @@ """Support for Broadlink sensors.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -11,6 +13,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import CONF_HOST, PERCENTAGE, POWER_WATT, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv @@ -21,29 +24,42 @@ from .helpers import import_device _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "temperature": ( - "Temperature", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), - "air_quality": ("Air Quality", None, None, None), - "humidity": ( - "Humidity", - PERCENTAGE, - DEVICE_CLASS_HUMIDITY, - STATE_CLASS_MEASUREMENT, + SensorEntityDescription( + key="air_quality", + name="Air Quality", ), - "light": ("Light", None, DEVICE_CLASS_ILLUMINANCE, None), - "noise": ("Noise", None, None, None), - "power": ( - "Current power", - POWER_WATT, - DEVICE_CLASS_POWER, - STATE_CLASS_MEASUREMENT, + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, ), -} + SensorEntityDescription( + key="light", + name="Light", + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SensorEntityDescription( + key="noise", + name="Noise", + ), + SensorEntityDescription( + key="power", + name="Current power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_HOST): cv.string}, extra=vol.ALLOW_EXTRA @@ -67,13 +83,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): device = hass.data[DOMAIN].devices[config_entry.entry_id] sensor_data = device.update_manager.coordinator.data sensors = [ - BroadlinkSensor(device, monitored_condition) - for monitored_condition in sensor_data - if monitored_condition in SENSOR_TYPES + BroadlinkSensor(device, description) + for description in SENSOR_TYPES + if description.key in sensor_data and ( # These devices have optional sensors. # We don't create entities if the value is 0. - sensor_data[monitored_condition] != 0 + sensor_data[description.key] != 0 or device.api.type not in {"RM4PRO", "RM4MINI"} ) ] @@ -83,18 +99,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BroadlinkSensor(BroadlinkEntity, SensorEntity): """Representation of a Broadlink sensor.""" - def __init__(self, device, monitored_condition): + def __init__(self, device, description: SensorEntityDescription): """Initialize the sensor.""" super().__init__(device) - self._monitored_condition = monitored_condition + self.entity_description = description - self._attr_device_class = SENSOR_TYPES[monitored_condition][2] - self._attr_name = f"{device.name} {SENSOR_TYPES[monitored_condition][0]}" - self._attr_state_class = SENSOR_TYPES[monitored_condition][3] - self._attr_state = self._coordinator.data[monitored_condition] - self._attr_unique_id = f"{device.unique_id}-{monitored_condition}" - self._attr_unit_of_measurement = SENSOR_TYPES[monitored_condition][1] + self._attr_name = f"{device.name} {description.name}" + self._attr_native_value = self._coordinator.data[description.key] + self._attr_unique_id = f"{device.unique_id}-{description.key}" def _update_state(self, data): """Update the state of the entity.""" - self._attr_state = data[self._monitored_condition] + self._attr_native_value = data[self.entity_description.key] diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 9fb7215e2a9..5ed1e424f53 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -142,9 +142,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): super().__init__(device) self._command_on = command_on self._command_off = command_off - - self._attr_assumed_state = True - self._attr_device_class = DEVICE_CLASS_SWITCH self._attr_name = f"{device.name} Switch" async def async_added_to_hass(self): diff --git a/homeassistant/components/broadlink/translations/es-419.json b/homeassistant/components/broadlink/translations/es-419.json new file mode 100644 index 00000000000..9c3129a2c6c --- /dev/null +++ b/homeassistant/components/broadlink/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "not_supported": "Dispositivo no compatible" + }, + "flow_title": "{name} ({model} en {host})", + "step": { + "auth": { + "title": "Autenticarse en el dispositivo" + }, + "finish": { + "title": "Elija un nombre para el dispositivo" + }, + "reset": { + "description": "{name} ({model} en {host}) est\u00e1 bloqueado. Debe desbloquear el dispositivo para autenticarse y completar la configuraci\u00f3n. Instrucciones:\n 1. Abra la aplicaci\u00f3n Broadlink.\n 2. Haga clic en el dispositivo.\n 3. Haga clic en `...` en la esquina superior derecha.\n 4. Despl\u00e1cese hasta el final de la p\u00e1gina.\n 5. Desactive el bloqueo.", + "title": "Desbloquear el dispositivo" + }, + "unlock": { + "data": { + "unlock": "S\u00ed, hazlo." + }, + "description": "{name} ({model} en {host}) est\u00e1 bloqueado. Esto puede provocar problemas de autenticaci\u00f3n en Home Assistant. \u00bfQuieres desbloquearlo?", + "title": "Desbloquear el dispositivo (opcional)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index 95ffcf063f2..8e34f9f983b 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -87,154 +87,154 @@ SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( key=ATTR_PAGE_COUNTER, icon="mdi:file-document-outline", name=ATTR_PAGE_COUNTER.replace("_", " ").title(), - unit_of_measurement=UNIT_PAGES, + native_unit_of_measurement=UNIT_PAGES, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BW_COUNTER, icon="mdi:file-document-outline", name=ATTR_BW_COUNTER.replace("_", " ").title(), - unit_of_measurement=UNIT_PAGES, + native_unit_of_measurement=UNIT_PAGES, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_COLOR_COUNTER, icon="mdi:file-document-outline", name=ATTR_COLOR_COUNTER.replace("_", " ").title(), - unit_of_measurement=UNIT_PAGES, + native_unit_of_measurement=UNIT_PAGES, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_DUPLEX_COUNTER, icon="mdi:file-document-outline", name=ATTR_DUPLEX_COUNTER.replace("_", " ").title(), - unit_of_measurement=UNIT_PAGES, + native_unit_of_measurement=UNIT_PAGES, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BLACK_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_CYAN_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_MAGENTA_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_YELLOW_DRUM_REMAINING_LIFE, icon="mdi:chart-donut", name=ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BELT_UNIT_REMAINING_LIFE, icon="mdi:current-ac", name=ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_FUSER_REMAINING_LIFE, icon="mdi:water-outline", name=ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_LASER_REMAINING_LIFE, icon="mdi:spotlight-beam", name=ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_PF_KIT_1_REMAINING_LIFE, icon="mdi:printer-3d", name=ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_PF_KIT_MP_REMAINING_LIFE, icon="mdi:printer-3d", name=ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BLACK_TONER_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_CYAN_TONER_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_MAGENTA_TONER_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_YELLOW_TONER_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BLACK_INK_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_CYAN_INK_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_MAGENTA_INK_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_YELLOW_INK_REMAINING, icon="mdi:printer-3d-nozzle", name=ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 0ff5c14d9cc..8dd150b48bf 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -64,7 +64,7 @@ class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): self.entity_description = description @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" if self.entity_description.key == ATTR_UPTIME: return cast( diff --git a/homeassistant/components/brother/translations/zh-Hans.json b/homeassistant/components/brother/translations/zh-Hans.json index 8f9e85e54a9..91e0c310dd1 100644 --- a/homeassistant/components/brother/translations/zh-Hans.json +++ b/homeassistant/components/brother/translations/zh-Hans.json @@ -1,8 +1,23 @@ { "config": { + "abort": { + "unsupported_model": "\u4e0d\u652f\u6301\u6b64\u6253\u5370\u673a\u578b\u53f7\u3002" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "snmp_error": "SNMP\u670d\u52a1\u5668\u5df2\u5173\u95ed\u6216\u4e0d\u652f\u6301\u6253\u5370\u3002" + }, + "step": { + "user": { + "description": "\u8bbe\u7f6e Brother \u6253\u5370\u673a\u96c6\u6210\u3002\u5982\u679c\u60a8\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/brother" + }, + "zeroconf_confirm": { + "data": { + "type": "\u6253\u5370\u673a\u7c7b\u578b" + }, + "description": "\u60a8\u662f\u5426\u8981\u5c06 Brother \u6253\u5370\u673a {model} (\u5e8f\u5217\u53f7:`{serial_number}`) \u6dfb\u52a0\u5230 Home Assistant ?", + "title": "\u5df2\u53d1\u73b0\u7684 Brother \u6253\u5370\u673a" + } } } } \ No newline at end of file diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index c327f9122ce..22d9ea8e5d8 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -103,4 +103,4 @@ class BrottsplatskartanSensor(SensorEntity): ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION } self._attr_extra_state_attributes.update(incident_counts) - self._attr_state = len(incidents) + self._attr_native_value = len(incidents) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 160c4f9d9b3..23259101224 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -21,6 +21,9 @@ from homeassistant.components.climate.const import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_NAME, ATTR_TEMPERATURE, TEMP_CELSIUS, @@ -29,14 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_TARGET_TEMPERATURE, - DATA_BSBLAN_CLIENT, - DOMAIN, -) +from .const import ATTR_TARGET_TEMPERATURE, DATA_BSBLAN_CLIENT, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index e65100af90d..0dc2e15a7b4 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -7,10 +7,6 @@ DATA_BSBLAN_CLIENT: Final = "bsblan_client" DATA_BSBLAN_TIMER: Final = "bsblan_timer" DATA_BSBLAN_UPDATED: Final = "bsblan_updated" -ATTR_IDENTIFIERS: Final = "identifiers" -ATTR_MODEL: Final = "model" -ATTR_MANUFACTURER: Final = "manufacturer" - ATTR_TARGET_TEMPERATURE: Final = "target_temperature" ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature" ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature" diff --git a/homeassistant/components/bsblan/translations/ca.json b/homeassistant/components/bsblan/translations/ca.json index 8a9e3f3e533..7bcae685f35 100644 --- a/homeassistant/components/bsblan/translations/ca.json +++ b/homeassistant/components/bsblan/translations/ca.json @@ -16,7 +16,7 @@ "port": "Port", "username": "Nom d'usuari" }, - "description": "Configura un dispositiu BSB-Lan per a integrar-lo amb Home Assistant.", + "description": "Configura la integraci\u00f3 d'un dispositiu BSB-Lan amb Home Assistant.", "title": "Connexi\u00f3 amb dispositiu BSB-Lan" } } diff --git a/homeassistant/components/bsblan/translations/hu.json b/homeassistant/components/bsblan/translations/hu.json index 499a7d92331..51feb8b75d7 100644 --- a/homeassistant/components/bsblan/translations/hu.json +++ b/homeassistant/components/bsblan/translations/hu.json @@ -11,10 +11,13 @@ "user": { "data": { "host": "Hoszt", + "passkey": "Jelsz\u00f3 karakterl\u00e1nc", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "\u00c1ll\u00edtsa be a BSB-Lan eszk\u00f6zt az HomeAssistantba val\u00f3 integr\u00e1ci\u00f3hoz.", + "title": "Csatlakoz\u00e1s a BSB-Lan eszk\u00f6zh\u00f6z" } } } diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 34f1f173319..91e4bcffb17 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -143,7 +143,9 @@ class BuienradarCam(Camera): _LOGGER.error("Failed to fetch image, %s", type(err)) return False - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """ Return a still image response from the camera. diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 7af84f48af7..2c6390f959b 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -1,4 +1,6 @@ """Support for Buienradar.nl weather service.""" +from __future__ import annotations + import logging from buienradar.constants import ( @@ -19,7 +21,7 @@ from buienradar.constants import ( WINDSPEED, ) -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, @@ -55,264 +57,582 @@ SCHEDULE_OK = 10 # When an error occurred, new call after (minutes): SCHEDULE_NOK = 2 -# Supported sensor types: -# Key: ['label', unit, icon] -SENSOR_TYPES = { - "stationname": ["Stationname", None, None, None], +STATIONNAME_LABEL = "Stationname" + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="stationname", + name=STATIONNAME_LABEL, + ), # new in json api (>1.0.0): - "barometerfc": ["Barometer value", None, "mdi:gauge", None], + SensorEntityDescription( + key="barometerfc", + name="Barometer value", + icon="mdi:gauge", + ), # new in json api (>1.0.0): - "barometerfcname": ["Barometer", None, "mdi:gauge", None], + SensorEntityDescription( + key="barometerfcname", + name="Barometer", + icon="mdi:gauge", + ), # new in json api (>1.0.0): - "barometerfcnamenl": ["Barometer", None, "mdi:gauge", None], - "condition": ["Condition", None, None, None], - "conditioncode": ["Condition code", None, None, None], - "conditiondetailed": ["Detailed condition", None, None, None], - "conditionexact": ["Full condition", None, None, None], - "symbol": ["Symbol", None, None, None], + SensorEntityDescription( + key="barometerfcnamenl", + name="Barometer", + icon="mdi:gauge", + ), + SensorEntityDescription( + key="condition", + name="Condition", + ), + SensorEntityDescription( + key="conditioncode", + name="Condition code", + ), + SensorEntityDescription( + key="conditiondetailed", + name="Detailed condition", + ), + SensorEntityDescription( + key="conditionexact", + name="Full condition", + ), + SensorEntityDescription( + key="symbol", + name="Symbol", + ), # new in json api (>1.0.0): - "feeltemperature": [ - "Feel temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "humidity": ["Humidity", PERCENTAGE, "mdi:water-percent", None], - "temperature": [ - "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "groundtemperature": [ - "Ground temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy", None], - "windforce": ["Wind force", "Bft", "mdi:weather-windy", None], - "winddirection": ["Wind direction", None, "mdi:compass-outline", None], - "windazimuth": ["Wind direction azimuth", DEGREE, "mdi:compass-outline", None], - "pressure": ["Pressure", PRESSURE_HPA, "mdi:gauge", None], - "visibility": ["Visibility", LENGTH_KILOMETERS, None, None], - "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy", None], - "precipitation": [ - "Precipitation", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:weather-pouring", - None, - ], - "irradiance": [ - "Irradiance", - IRRADIATION_WATTS_PER_SQUARE_METER, - "mdi:sunglasses", - None, - ], - "precipitation_forecast_average": [ - "Precipitation forecast average", - PRECIPITATION_MILLIMETERS_PER_HOUR, - "mdi:weather-pouring", - None, - ], - "precipitation_forecast_total": [ - "Precipitation forecast total", - LENGTH_MILLIMETERS, - "mdi:weather-pouring", - None, - ], + SensorEntityDescription( + key="feeltemperature", + name="Feel temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="groundtemperature", + name="Ground temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="windspeed", + name="Wind speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce", + name="Wind force", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="winddirection", + name="Wind direction", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth", + name="Wind direction azimuth", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="pressure", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="visibility", + name="Visibility", + native_unit_of_measurement=LENGTH_KILOMETERS, + ), + SensorEntityDescription( + key="windgust", + name="Wind gust", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="precipitation", + name="Precipitation", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="irradiance", + name="Irradiance", + native_unit_of_measurement=IRRADIATION_WATTS_PER_SQUARE_METER, + icon="mdi:sunglasses", + ), + SensorEntityDescription( + key="precipitation_forecast_average", + name="Precipitation forecast average", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="precipitation_forecast_total", + name="Precipitation forecast total", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "rainlast24hour": [ - "Rain last 24h", - LENGTH_MILLIMETERS, - "mdi:weather-pouring", - None, - ], + SensorEntityDescription( + key="rainlast24hour", + name="Rain last 24h", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "rainlasthour": ["Rain last hour", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "temperature_1d": [ - "Temperature 1d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_2d": [ - "Temperature 2d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_3d": [ - "Temperature 3d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_4d": [ - "Temperature 4d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "temperature_5d": [ - "Temperature 5d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_1d": [ - "Minimum temperature 1d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_2d": [ - "Minimum temperature 2d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_3d": [ - "Minimum temperature 3d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_4d": [ - "Minimum temperature 4d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "mintemp_5d": [ - "Minimum temperature 5d", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "rain_1d": ["Rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rain_2d": ["Rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rain_3d": ["Rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rain_4d": ["Rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rain_5d": ["Rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + SensorEntityDescription( + key="rainlasthour", + name="Rain last hour", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="temperature_1d", + name="Temperature 1d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_2d", + name="Temperature 2d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_3d", + name="Temperature 3d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_4d", + name="Temperature 4d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="temperature_5d", + name="Temperature 5d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_1d", + name="Minimum temperature 1d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_2d", + name="Minimum temperature 2d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_3d", + name="Minimum temperature 3d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_4d", + name="Minimum temperature 4d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="mintemp_5d", + name="Minimum temperature 5d", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="rain_1d", + name="Rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_2d", + name="Rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_3d", + name="Rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_4d", + name="Rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rain_5d", + name="Rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "minrain_1d": ["Minimum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "minrain_2d": ["Minimum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "minrain_3d": ["Minimum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "minrain_4d": ["Minimum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "minrain_5d": ["Minimum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + SensorEntityDescription( + key="minrain_1d", + name="Minimum rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_2d", + name="Minimum rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_3d", + name="Minimum rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_4d", + name="Minimum rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="minrain_5d", + name="Minimum rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), # new in json api (>1.0.0): - "maxrain_1d": ["Maximum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "maxrain_2d": ["Maximum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "maxrain_3d": ["Maximum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "maxrain_4d": ["Maximum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "maxrain_5d": ["Maximum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], - "rainchance_1d": ["Rainchance 1d", PERCENTAGE, "mdi:weather-pouring", None], - "rainchance_2d": ["Rainchance 2d", PERCENTAGE, "mdi:weather-pouring", None], - "rainchance_3d": ["Rainchance 3d", PERCENTAGE, "mdi:weather-pouring", None], - "rainchance_4d": ["Rainchance 4d", PERCENTAGE, "mdi:weather-pouring", None], - "rainchance_5d": ["Rainchance 5d", PERCENTAGE, "mdi:weather-pouring", None], - "sunchance_1d": ["Sunchance 1d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "sunchance_2d": ["Sunchance 2d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "sunchance_3d": ["Sunchance 3d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "sunchance_4d": ["Sunchance 4d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "sunchance_5d": ["Sunchance 5d", PERCENTAGE, "mdi:weather-partly-cloudy", None], - "windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy", None], - "windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy", None], - "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy", None], - "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy", None], - "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy", None], - "windspeed_1d": [ - "Wind speed 1d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "windspeed_2d": [ - "Wind speed 2d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "windspeed_3d": [ - "Wind speed 3d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "windspeed_4d": [ - "Wind speed 4d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "windspeed_5d": [ - "Wind speed 5d", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - ], - "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline", None], - "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline", None], - "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline", None], - "winddirection_4d": ["Wind direction 4d", None, "mdi:compass-outline", None], - "winddirection_5d": ["Wind direction 5d", None, "mdi:compass-outline", None], - "windazimuth_1d": [ - "Wind direction azimuth 1d", - DEGREE, - "mdi:compass-outline", - None, - ], - "windazimuth_2d": [ - "Wind direction azimuth 2d", - DEGREE, - "mdi:compass-outline", - None, - ], - "windazimuth_3d": [ - "Wind direction azimuth 3d", - DEGREE, - "mdi:compass-outline", - None, - ], - "windazimuth_4d": [ - "Wind direction azimuth 4d", - DEGREE, - "mdi:compass-outline", - None, - ], - "windazimuth_5d": [ - "Wind direction azimuth 5d", - DEGREE, - "mdi:compass-outline", - None, - ], - "condition_1d": ["Condition 1d", None, None, None], - "condition_2d": ["Condition 2d", None, None, None], - "condition_3d": ["Condition 3d", None, None, None], - "condition_4d": ["Condition 4d", None, None, None], - "condition_5d": ["Condition 5d", None, None, None], - "conditioncode_1d": ["Condition code 1d", None, None, None], - "conditioncode_2d": ["Condition code 2d", None, None, None], - "conditioncode_3d": ["Condition code 3d", None, None, None], - "conditioncode_4d": ["Condition code 4d", None, None, None], - "conditioncode_5d": ["Condition code 5d", None, None, None], - "conditiondetailed_1d": ["Detailed condition 1d", None, None, None], - "conditiondetailed_2d": ["Detailed condition 2d", None, None, None], - "conditiondetailed_3d": ["Detailed condition 3d", None, None, None], - "conditiondetailed_4d": ["Detailed condition 4d", None, None, None], - "conditiondetailed_5d": ["Detailed condition 5d", None, None, None], - "conditionexact_1d": ["Full condition 1d", None, None, None], - "conditionexact_2d": ["Full condition 2d", None, None, None], - "conditionexact_3d": ["Full condition 3d", None, None, None], - "conditionexact_4d": ["Full condition 4d", None, None, None], - "conditionexact_5d": ["Full condition 5d", None, None, None], - "symbol_1d": ["Symbol 1d", None, None, None], - "symbol_2d": ["Symbol 2d", None, None, None], - "symbol_3d": ["Symbol 3d", None, None, None], - "symbol_4d": ["Symbol 4d", None, None, None], - "symbol_5d": ["Symbol 5d", None, None, None], -} + SensorEntityDescription( + key="maxrain_1d", + name="Maximum rain 1d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_2d", + name="Maximum rain 2d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_3d", + name="Maximum rain 3d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_4d", + name="Maximum rain 4d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="maxrain_5d", + name="Maximum rain 5d", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_1d", + name="Rainchance 1d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_2d", + name="Rainchance 2d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_3d", + name="Rainchance 3d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_4d", + name="Rainchance 4d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="rainchance_5d", + name="Rainchance 5d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-pouring", + ), + SensorEntityDescription( + key="sunchance_1d", + name="Sunchance 1d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_2d", + name="Sunchance 2d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_3d", + name="Sunchance 3d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_4d", + name="Sunchance 4d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="sunchance_5d", + name="Sunchance 5d", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:weather-partly-cloudy", + ), + SensorEntityDescription( + key="windforce_1d", + name="Wind force 1d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_2d", + name="Wind force 2d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_3d", + name="Wind force 3d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_4d", + name="Wind force 4d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windforce_5d", + name="Wind force 5d", + native_unit_of_measurement="Bft", + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_1d", + name="Wind speed 1d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_2d", + name="Wind speed 2d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_3d", + name="Wind speed 3d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_4d", + name="Wind speed 4d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="windspeed_5d", + name="Wind speed 5d", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + SensorEntityDescription( + key="winddirection_1d", + name="Wind direction 1d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_2d", + name="Wind direction 2d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_3d", + name="Wind direction 3d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_4d", + name="Wind direction 4d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="winddirection_5d", + name="Wind direction 5d", + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_1d", + name="Wind direction azimuth 1d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_2d", + name="Wind direction azimuth 2d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_3d", + name="Wind direction azimuth 3d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_4d", + name="Wind direction azimuth 4d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="windazimuth_5d", + name="Wind direction azimuth 5d", + native_unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + SensorEntityDescription( + key="condition_1d", + name="Condition 1d", + ), + SensorEntityDescription( + key="condition_2d", + name="Condition 2d", + ), + SensorEntityDescription( + key="condition_3d", + name="Condition 3d", + ), + SensorEntityDescription( + key="condition_4d", + name="Condition 4d", + ), + SensorEntityDescription( + key="condition_5d", + name="Condition 5d", + ), + SensorEntityDescription( + key="conditioncode_1d", + name="Condition code 1d", + ), + SensorEntityDescription( + key="conditioncode_2d", + name="Condition code 2d", + ), + SensorEntityDescription( + key="conditioncode_3d", + name="Condition code 3d", + ), + SensorEntityDescription( + key="conditioncode_4d", + name="Condition code 4d", + ), + SensorEntityDescription( + key="conditioncode_5d", + name="Condition code 5d", + ), + SensorEntityDescription( + key="conditiondetailed_1d", + name="Detailed condition 1d", + ), + SensorEntityDescription( + key="conditiondetailed_2d", + name="Detailed condition 2d", + ), + SensorEntityDescription( + key="conditiondetailed_3d", + name="Detailed condition 3d", + ), + SensorEntityDescription( + key="conditiondetailed_4d", + name="Detailed condition 4d", + ), + SensorEntityDescription( + key="conditiondetailed_5d", + name="Detailed condition 5d", + ), + SensorEntityDescription( + key="conditionexact_1d", + name="Full condition 1d", + ), + SensorEntityDescription( + key="conditionexact_2d", + name="Full condition 2d", + ), + SensorEntityDescription( + key="conditionexact_3d", + name="Full condition 3d", + ), + SensorEntityDescription( + key="conditionexact_4d", + name="Full condition 4d", + ), + SensorEntityDescription( + key="conditionexact_5d", + name="Full condition 5d", + ), + SensorEntityDescription( + key="symbol_1d", + name="Symbol 1d", + ), + SensorEntityDescription( + key="symbol_2d", + name="Symbol 2d", + ), + SensorEntityDescription( + key="symbol_3d", + name="Symbol 3d", + ), + SensorEntityDescription( + key="symbol_4d", + name="Symbol 4d", + ), + SensorEntityDescription( + key="symbol_5d", + name="Symbol 5d", + ), +) async def async_setup_entry( @@ -342,8 +662,8 @@ async def async_setup_entry( ) entities = [ - BrSensor(sensor_type, config.get(CONF_NAME, "Buienradar"), coordinates) - for sensor_type in SENSOR_TYPES + BrSensor(config.get(CONF_NAME, "Buienradar"), coordinates, description) + for description in SENSOR_TYPES ] async_add_entities(entities) @@ -359,30 +679,27 @@ class BrSensor(SensorEntity): _attr_entity_registry_enabled_default = False _attr_should_poll = False - def __init__(self, sensor_type, client_name, coordinates): + def __init__(self, client_name, coordinates, description: SensorEntityDescription): """Initialize the sensor.""" - self._attr_name = f"{client_name} {SENSOR_TYPES[sensor_type][0]}" - self._attr_icon = SENSOR_TYPES[sensor_type][2] - self.type = sensor_type - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.entity_description = description + self._attr_name = f"{client_name} {description.name}" self._measured = None self._attr_unique_id = "{:2.6f}{:2.6f}{}".format( - coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], sensor_type + coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], description.key ) - self._attr_device_class = SENSOR_TYPES[sensor_type][3] # All continuous sensors should be forced to be updated - self._attr_force_update = sensor_type != SYMBOL and not sensor_type.startswith( - CONDITION + self._attr_force_update = ( + description.key != SYMBOL and not description.key.startswith(CONDITION) ) - if sensor_type.startswith(PRECIPITATION_FORECAST): + if description.key.startswith(PRECIPITATION_FORECAST): self._timeframe = None @callback def data_updated(self, data): """Update data.""" - if self._load_data(data) and self.hass: + if self.hass and self._load_data(data): self.async_write_ha_state() @callback @@ -396,28 +713,29 @@ class BrSensor(SensorEntity): return False self._measured = data.get(MEASURED) + sensor_type = self.entity_description.key if ( - self.type.endswith("_1d") - or self.type.endswith("_2d") - or self.type.endswith("_3d") - or self.type.endswith("_4d") - or self.type.endswith("_5d") + sensor_type.endswith("_1d") + or sensor_type.endswith("_2d") + or sensor_type.endswith("_3d") + or sensor_type.endswith("_4d") + or sensor_type.endswith("_5d") ): # update forcasting sensors: fcday = 0 - if self.type.endswith("_2d"): + if sensor_type.endswith("_2d"): fcday = 1 - if self.type.endswith("_3d"): + if sensor_type.endswith("_3d"): fcday = 2 - if self.type.endswith("_4d"): + if sensor_type.endswith("_4d"): fcday = 3 - if self.type.endswith("_5d"): + if sensor_type.endswith("_5d"): fcday = 4 # update weather symbol & status text - if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): + if sensor_type.startswith(SYMBOL) or sensor_type.startswith(CONDITION): try: condition = data.get(FORECAST)[fcday].get(CONDITION) except IndexError: @@ -426,29 +744,31 @@ class BrSensor(SensorEntity): if condition: new_state = condition.get(CONDITION) - if self.type.startswith(SYMBOL): + if sensor_type.startswith(SYMBOL): new_state = condition.get(EXACTNL) - if self.type.startswith("conditioncode"): + if sensor_type.startswith("conditioncode"): new_state = condition.get(CONDCODE) - if self.type.startswith("conditiondetailed"): + if sensor_type.startswith("conditiondetailed"): new_state = condition.get(DETAILED) - if self.type.startswith("conditionexact"): + if sensor_type.startswith("conditionexact"): new_state = condition.get(EXACT) img = condition.get(IMAGE) if new_state != self.state or img != self.entity_picture: - self._attr_state = new_state + self._attr_native_value = new_state self._attr_entity_picture = img return True return False - if self.type.startswith(WINDSPEED): + if sensor_type.startswith(WINDSPEED): # hass wants windspeeds in km/h not m/s, so convert: try: - self._attr_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + self._attr_native_value = data.get(FORECAST)[fcday].get( + sensor_type[:-3] + ) if self.state is not None: - self._attr_state = round(self.state * 3.6, 1) + self._attr_native_value = round(self.state * 3.6, 1) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) @@ -456,60 +776,64 @@ class BrSensor(SensorEntity): # update all other sensors try: - self._attr_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + self._attr_native_value = data.get(FORECAST)[fcday].get( + sensor_type[:-3] + ) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) return False - if self.type == SYMBOL or self.type.startswith(CONDITION): + if sensor_type == SYMBOL or sensor_type.startswith(CONDITION): # update weather symbol & status text condition = data.get(CONDITION) if condition: - if self.type == SYMBOL: + if sensor_type == SYMBOL: new_state = condition.get(EXACTNL) - if self.type == CONDITION: + if sensor_type == CONDITION: new_state = condition.get(CONDITION) - if self.type == "conditioncode": + if sensor_type == "conditioncode": new_state = condition.get(CONDCODE) - if self.type == "conditiondetailed": + if sensor_type == "conditiondetailed": new_state = condition.get(DETAILED) - if self.type == "conditionexact": + if sensor_type == "conditionexact": new_state = condition.get(EXACT) img = condition.get(IMAGE) if new_state != self.state or img != self.entity_picture: - self._attr_state = new_state + self._attr_native_value = new_state self._attr_entity_picture = img return True return False - if self.type.startswith(PRECIPITATION_FORECAST): + if sensor_type.startswith(PRECIPITATION_FORECAST): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) self._timeframe = nested.get(TIMEFRAME) - self._attr_state = nested.get(self.type[len(PRECIPITATION_FORECAST) + 1 :]) + self._attr_native_value = nested.get( + sensor_type[len(PRECIPITATION_FORECAST) + 1 :] + ) return True - if self.type in [WINDSPEED, WINDGUST]: + if sensor_type in [WINDSPEED, WINDGUST]: # hass wants windspeeds in km/h not m/s, so convert: - self._attr_state = data.get(self.type) + self._attr_native_value = data.get(sensor_type) if self.state is not None: - self._attr_state = round(data.get(self.type) * 3.6, 1) + self._attr_native_value = round(data.get(sensor_type) * 3.6, 1) return True - if self.type == VISIBILITY: + if sensor_type == VISIBILITY: # hass wants visibility in km (not m), so convert: - self._attr_state = data.get(self.type) + self._attr_native_value = data.get(sensor_type) if self.state is not None: - self._attr_state = round(self.state / 1000, 1) + self._attr_native_value = round(self.state / 1000, 1) return True # update all other sensors - self._attr_state = data.get(self.type) - if self.type.startswith(PRECIPITATION_FORECAST): + self._attr_native_value = data.get(sensor_type) + if sensor_type.startswith(PRECIPITATION_FORECAST): result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)} if self._timeframe is not None: result[TIMEFRAME_LABEL] = "%d min" % (self._timeframe) @@ -518,7 +842,7 @@ class BrSensor(SensorEntity): result = { ATTR_ATTRIBUTION: data.get(ATTRIBUTION), - SENSOR_TYPES["stationname"][0]: data.get(STATIONNAME), + STATIONNAME_LABEL: data.get(STATIONNAME), } if self._measured is not None: # convert datetime (Europe/Amsterdam) into local datetime diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index d1f354cc78e..9724e8e1e70 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -8,7 +8,9 @@ from collections.abc import Awaitable, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta +from functools import partial import hashlib +import inspect import logging import os from random import SystemRandom @@ -62,6 +64,7 @@ from .const import ( DOMAIN, SERVICE_RECORD, ) +from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences # mypy: allow-untyped-calls @@ -138,23 +141,68 @@ async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> return await _async_stream_endpoint_url(hass, camera, fmt) -@bind_hass -async def async_get_image( - hass: HomeAssistant, entity_id: str, timeout: int = 10 +async def _async_get_image( + camera: Camera, + timeout: int = 10, + width: int | None = None, + height: int | None = None, ) -> Image: - """Fetch an image from a camera entity.""" - camera = _get_camera_from_entity_id(hass, entity_id) + """Fetch a snapshot image from a camera. + If width and height are passed, an attempt to scale + the image will be made on a best effort basis. + Not all cameras can scale images or return jpegs + that we can scale, however the majority of cases + are handled. + """ with suppress(asyncio.CancelledError, asyncio.TimeoutError): async with async_timeout.timeout(timeout): - image = await camera.async_camera_image() + # Calling inspect will be removed in 2022.1 after all + # custom components have had a chance to change their signature + sig = inspect.signature(camera.async_camera_image) + if "height" in sig.parameters and "width" in sig.parameters: + image_bytes = await camera.async_camera_image( + width=width, height=height + ) + else: + camera.async_warn_old_async_camera_image_signature() + image_bytes = await camera.async_camera_image() - if image: - return Image(camera.content_type, image) + if image_bytes: + content_type = camera.content_type + image = Image(content_type, image_bytes) + if ( + width is not None + and height is not None + and ("jpeg" in content_type or "jpg" in content_type) + ): + assert width is not None + assert height is not None + return Image( + content_type, scale_jpeg_camera_image(image, width, height) + ) + + return image raise HomeAssistantError("Unable to get image") +@bind_hass +async def async_get_image( + hass: HomeAssistant, + entity_id: str, + timeout: int = 10, + width: int | None = None, + height: int | None = None, +) -> Image: + """Fetch an image from a camera entity. + + width and height will be passed to the underlying camera. + """ + camera = _get_camera_from_entity_id(hass, entity_id) + return await _async_get_image(camera, timeout, width, height) + + @bind_hass async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: """Fetch the stream source for a camera entity.""" @@ -330,6 +378,7 @@ class Camera(Entity): self.stream_options: dict[str, str] = {} self.content_type: str = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) + self._warned_old_signature = False self.async_update_token() @property @@ -387,14 +436,38 @@ class Camera(Entity): """Return the source of the stream.""" return None - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" raise NotImplementedError() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" + sig = inspect.signature(self.camera_image) + # Calling inspect will be removed in 2022.1 after all + # custom components have had a chance to change their signature + if "height" in sig.parameters and "width" in sig.parameters: + return await self.hass.async_add_executor_job( + partial(self.camera_image, width=width, height=height) + ) + self.async_warn_old_async_camera_image_signature() return await self.hass.async_add_executor_job(self.camera_image) + # Remove in 2022.1 after all custom components have had a chance to change their signature + @callback + def async_warn_old_async_camera_image_signature(self) -> None: + """Warn once when calling async_camera_image with the function old signature.""" + if self._warned_old_signature: + return + _LOGGER.warning( + "The camera entity %s does not support requesting width and height, please open an issue with the integration author", + self.entity_id, + ) + self._warned_old_signature = True + async def handle_async_still_stream( self, request: web.Request, interval: float ) -> web.StreamResponse: @@ -529,14 +602,19 @@ class CameraImageView(CameraView): async def handle(self, request: web.Request, camera: Camera) -> web.Response: """Serve camera image.""" - with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(CAMERA_IMAGE_TIMEOUT): - image = await camera.async_camera_image() - - if image: - return web.Response(body=image, content_type=camera.content_type) - - raise web.HTTPInternalServerError() + width = request.query.get("width") + height = request.query.get("height") + try: + image = await _async_get_image( + camera, + CAMERA_IMAGE_TIMEOUT, + int(width) if width else None, + int(height) if height else None, + ) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError() from ex + else: + return web.Response(body=image.content, content_type=image.content_type) class CameraMjpegStream(CameraView): diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py new file mode 100644 index 00000000000..3aadc5c454c --- /dev/null +++ b/homeassistant/components/camera/img_util.py @@ -0,0 +1,101 @@ +"""Image processing for cameras.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +SUPPORTED_SCALING_FACTORS = [(7, 8), (3, 4), (5, 8), (1, 2), (3, 8), (1, 4), (1, 8)] + +_LOGGER = logging.getLogger(__name__) + +JPEG_QUALITY = 75 + +if TYPE_CHECKING: + from turbojpeg import TurboJPEG + + from . import Image + + +def find_supported_scaling_factor( + current_width: int, current_height: int, target_width: int, target_height: int +) -> tuple[int, int] | None: + """Find a supported scaling factor to scale the image. + + If there is no exact match, we use one size up to ensure + the image remains crisp. + """ + for idx, supported_sf in enumerate(SUPPORTED_SCALING_FACTORS): + ratio = supported_sf[0] / supported_sf[1] + width_after_scale = current_width * ratio + height_after_scale = current_height * ratio + if width_after_scale == target_width and height_after_scale == target_height: + return supported_sf + if width_after_scale < target_width or height_after_scale < target_height: + return None if idx == 0 else SUPPORTED_SCALING_FACTORS[idx - 1] + + # Giant image, the most we can reduce by is 1/8 + return SUPPORTED_SCALING_FACTORS[-1] + + +def scale_jpeg_camera_image(cam_image: Image, width: int, height: int) -> bytes: + """Scale a camera image as close as possible to one of the supported scaling factors.""" + turbo_jpeg = TurboJPEGSingleton.instance() + if not turbo_jpeg: + return cam_image.content + + try: + (current_width, current_height, _, _) = turbo_jpeg.decode_header( + cam_image.content + ) + except OSError: + return cam_image.content + + scaling_factor = find_supported_scaling_factor( + current_width, current_height, width, height + ) + if scaling_factor is None: + return cam_image.content + + return cast( + bytes, + turbo_jpeg.scale_with_quality( + cam_image.content, + scaling_factor=scaling_factor, + quality=JPEG_QUALITY, + ), + ) + + +class TurboJPEGSingleton: + """ + Load TurboJPEG only once. + + Ensures we do not log load failures each snapshot + since camera image fetches happen every few + seconds. + """ + + __instance = None + + @staticmethod + def instance() -> TurboJPEG: + """Singleton for TurboJPEG.""" + if TurboJPEGSingleton.__instance is None: + TurboJPEGSingleton() + return TurboJPEGSingleton.__instance + + def __init__(self) -> None: + """Try to create TurboJPEG only once.""" + try: + # TurboJPEG checks for libturbojpeg + # when its created, but it imports + # numpy which may or may not work so + # we have to guard the import here. + from turbojpeg import TurboJPEG # pylint: disable=import-outside-toplevel + + TurboJPEGSingleton.__instance = TurboJPEG() + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error loading libturbojpeg; Cameras may impact HomeKit performance" + ) + TurboJPEGSingleton.__instance = False diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index ed8e10c1956..4c3ab704e1f 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -3,6 +3,7 @@ "name": "Camera", "documentation": "https://www.home-assistant.io/integrations/camera", "dependencies": ["http"], + "requirements": ["PyTurboJPEG==1.5.2"], "after_dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 2699ba1f640..a475a27f942 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -1,7 +1,6 @@ """Support for Canary camera.""" from __future__ import annotations -import asyncio from datetime import timedelta from typing import Final @@ -9,9 +8,9 @@ from aiohttp.web import Request, StreamResponse from canary.api import Device, Location from canary.live_stream_api import LiveStreamSession from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, Camera, @@ -123,22 +122,21 @@ class CanaryCamera(CoordinatorEntity, Camera): """Return the camera motion detection status.""" return not self.location.is_recording - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" await self.hass.async_add_executor_job(self.renew_live_stream_session) live_stream_url = await self.hass.async_add_executor_job( getattr, self._live_stream_session, "live_stream_url" ) - - ffmpeg = ImageFrame(self._ffmpeg.binary) - image: bytes | None = await asyncio.shield( - ffmpeg.get_image( - live_stream_url, - output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments, - ) + return await ffmpeg.async_get_image( + self.hass, + live_stream_url, + extra_cmd=self._ffmpeg_arguments, + width=width, + height=height, ) - return image async def handle_async_mjpeg_stream( self, request: Request diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index acb885055a3..1e7747039b8 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -120,7 +120,7 @@ class CanarySensor(CoordinatorEntity, SensorEntity): "model": device.device_type["name"], "manufacturer": MANUFACTURER, } - self._attr_unit_of_measurement = sensor_type[1] + self._attr_native_unit_of_measurement = sensor_type[1] self._attr_device_class = sensor_type[3] self._attr_icon = sensor_type[2] @@ -144,7 +144,7 @@ class CanarySensor(CoordinatorEntity, SensorEntity): return None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" return self.reading diff --git a/homeassistant/components/canary/translations/es-419.json b/homeassistant/components/canary/translations/es-419.json new file mode 100644 index 00000000000..8ce6a8fb855 --- /dev/null +++ b/homeassistant/components/canary/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Conectarse a Canary" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "Argumentos pasados a ffmpeg para c\u00e1maras", + "timeout": "Solicitar tiempo de espera (segundos)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/translations/es-419.json b/homeassistant/components/cast/translations/es-419.json index fd893b9680f..ffcc09b23ca 100644 --- a/homeassistant/components/cast/translations/es-419.json +++ b/homeassistant/components/cast/translations/es-419.json @@ -3,10 +3,42 @@ "abort": { "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." }, + "error": { + "invalid_known_hosts": "Los hosts conocidos deben ser una lista de hosts separados por comas." + }, "step": { + "config": { + "data": { + "known_hosts": "Hosts conocidos" + }, + "description": "Hosts conocidos: una lista separada por comas de nombres de host o direcciones IP de dispositivos de transmisi\u00f3n, que se utiliza si el descubrimiento de mDNS no funciona.", + "title": "Configuraci\u00f3n de Google Cast" + }, "confirm": { "description": "\u00bfDesea configurar Google Cast?" } } + }, + "options": { + "error": { + "invalid_known_hosts": "Los hosts conocidos deben ser una lista de hosts separados por comas." + }, + "step": { + "advanced_options": { + "data": { + "ignore_cec": "Ignorar CEC", + "uuid": "UUID permitidos" + }, + "description": "UUID permitidos: una lista separada por comas de UUID de dispositivos de transmisi\u00f3n para agregar a Home Assistant. \u00daselo solo si no desea agregar todos los dispositivos de transmisi\u00f3n disponibles.\nIgnorar CEC: una lista separada por comas de Chromecasts que debe ignorar los datos de CEC para determinar la entrada activa. Esto se pasar\u00e1 a pychromecast.IGNORE_CEC.", + "title": "Configuraci\u00f3n avanzada de Google Cast" + }, + "basic_options": { + "data": { + "known_hosts": "Hosts conocidos" + }, + "description": "Hosts conocidos: una lista separada por comas de nombres de host o direcciones IP de dispositivos de transmisi\u00f3n, que se utiliza si el descubrimiento de mDNS no funciona.", + "title": "Configuraci\u00f3n de Google Cast" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 787465bb6f3..7b6445a2f35 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -85,7 +85,7 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: return self.coordinator.data.isoformat() diff --git a/homeassistant/components/cert_expiry/translations/hu.json b/homeassistant/components/cert_expiry/translations/hu.json index 2ae516565e3..de459c324df 100644 --- a/homeassistant/components/cert_expiry/translations/hu.json +++ b/homeassistant/components/cert_expiry/translations/hu.json @@ -4,14 +4,21 @@ "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", "import_failed": "Nem siker\u00fclt import\u00e1lni a konfigur\u00e1ci\u00f3t" }, + "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" + }, "step": { "user": { "data": { "host": "Hoszt", "name": "A tan\u00fas\u00edtv\u00e1ny neve", "port": "Port" - } + }, + "title": "Hat\u00e1rozza meg a vizsg\u00e1land\u00f3 tan\u00fas\u00edtv\u00e1nyt" } } - } + }, + "title": "Tan\u00fas\u00edtv\u00e1ny lej\u00e1rata" } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/zh-Hans.json b/homeassistant/components/cert_expiry/translations/zh-Hans.json index 07affc990a8..201749ae796 100644 --- a/homeassistant/components/cert_expiry/translations/zh-Hans.json +++ b/homeassistant/components/cert_expiry/translations/zh-Hans.json @@ -1,15 +1,22 @@ { "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", + "import_failed": "\u914d\u7f6e\u5bfc\u5165\u5931\u8d25" + }, "error": { - "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6" + "connection_refused": "\u8fde\u63a5\u5230\u4e3b\u673a\u65f6\u88ab\u62d2\u7edd\u8fde\u63a5", + "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6", + "resolve_failed": "\u65e0\u6cd5\u89e3\u6790\u4e3b\u673a" }, "step": { "user": { "data": { - "host": "\u8bc1\u4e66\u7684\u4e3b\u673a\u540d", + "host": "\u4e3b\u673a\u5730\u5740", "name": "\u8bc1\u4e66\u7684\u540d\u79f0", "port": "\u8bc1\u4e66\u7684\u7aef\u53e3" - } + }, + "title": "\u5b9a\u4e49\u8981\u6d4b\u8bd5\u7684\u8bc1\u4e66" } } } diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py index 2b62039989a..bfdc12c35ce 100644 --- a/homeassistant/components/channels/media_player.py +++ b/homeassistant/components/channels/media_player.py @@ -255,7 +255,7 @@ class ChannelsPlayer(MediaPlayerEntity): if media_type == MEDIA_TYPE_CHANNEL: response = self.client.play_channel(media_id) self.update_state(response) - elif media_type in [MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, MEDIA_TYPE_TVSHOW]: + elif media_type in (MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, MEDIA_TYPE_TVSHOW): response = self.client.play_recording(media_id) self.update_state(response) diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 7d54d259051..fd0c96c6fbe 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -265,7 +265,7 @@ class CityBikesNetwork: class CityBikesStation(SensorEntity): """CityBikes API Sensor.""" - _attr_unit_of_measurement = "bikes" + _attr_native_unit_of_measurement = "bikes" _attr_icon = "mdi:bike" def __init__(self, network, station_id, entity_id): @@ -281,7 +281,7 @@ class CityBikesStation(SensorEntity): station_data = station break self._attr_name = station_data.get(ATTR_NAME) - self._attr_state = station_data.get(ATTR_FREE_BIKES) + self._attr_native_value = station_data.get(ATTR_FREE_BIKES) self._attr_extra_state_attributes = ( { ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION, diff --git a/homeassistant/components/climacell/sensor.py b/homeassistant/components/climacell/sensor.py index 3f96dd9e02c..1ba5bbe3a34 100644 --- a/homeassistant/components/climacell/sensor.py +++ b/homeassistant/components/climacell/sensor.py @@ -68,7 +68,7 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): f"{self._config_entry.unique_id}_{slugify(description.name)}" ) self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} - self._attr_unit_of_measurement = ( + self._attr_native_unit_of_measurement = ( description.unit_metric if hass.config.units.is_metric else description.unit_imperial @@ -80,7 +80,7 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): """Return the raw state.""" @property - def state(self) -> str | int | float | None: + def native_value(self) -> str | int | float | None: """Return the state.""" state = self._state if ( diff --git a/homeassistant/components/climacell/translations/es-419.json b/homeassistant/components/climacell/translations/es-419.json new file mode 100644 index 00000000000..deb60db2004 --- /dev/null +++ b/homeassistant/components/climacell/translations/es-419.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "rate_limited": "Actualmente la tarifa est\u00e1 limitada. Vuelve a intentarlo m\u00e1s tarde." + }, + "step": { + "user": { + "data": { + "api_version": "Versi\u00f3n de la API" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "Min. entre pron\u00f3sticos de NowCast" + }, + "description": "Si elige habilitar la entidad de pron\u00f3stico \"nowcast\", puede configurar el n\u00famero de minutos entre cada pron\u00f3stico. El n\u00famero de pron\u00f3sticos proporcionados depende del n\u00famero de minutos elegidos entre los pron\u00f3sticos." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 6a46c1986b8..98e37237792 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -504,7 +504,6 @@ class ClimateEntity(Entity): async def async_turn_on(self) -> None: """Turn the entity on.""" if hasattr(self, "turn_on"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.turn_on) # type: ignore[attr-defined] return @@ -518,7 +517,6 @@ class ClimateEntity(Entity): async def async_turn_off(self) -> None: """Turn the entity off.""" if hasattr(self, "turn_off"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.turn_off) # type: ignore[attr-defined] return diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 6afc4d294cb..34217e8872d 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -38,7 +38,9 @@ SET_PRESET_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ACTION_SCHEMA = vol.Any(SET_HVAC_MODE_SCHEMA, SET_PRESET_MODE_SCHEMA) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Climate devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 1b5127d7d4a..4ff2e8fe477 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Climate.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -58,7 +60,9 @@ CURRENT_TRIGGER_SCHEMA = vol.All( TRIGGER_SCHEMA = vol.Any(HVAC_MODE_TRIGGER_SCHEMA, CURRENT_TRIGGER_SCHEMA) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Climate devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -158,12 +162,14 @@ async def async_attach_trigger( ) -async def async_get_trigger_capabilities(hass: HomeAssistant, config): +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" trigger_type = config[CONF_TYPE] if trigger_type == "hvac_action_changed": - return None + return {} if trigger_type == "hvac_mode_changed": return { diff --git a/homeassistant/components/cloud/translations/pl.json b/homeassistant/components/cloud/translations/pl.json index 1df32a14d8e..30aaeeb77d1 100644 --- a/homeassistant/components/cloud/translations/pl.json +++ b/homeassistant/components/cloud/translations/pl.json @@ -4,7 +4,7 @@ "alexa_enabled": "Alexa w\u0142\u0105czona", "can_reach_cert_server": "Dost\u0119p do serwera certyfikat\u00f3w", "can_reach_cloud": "Dost\u0119p do chmury Home Assistant", - "can_reach_cloud_auth": "Dost\u0119p do serwera certyfikat\u00f3w", + "can_reach_cloud_auth": "Dost\u0119p do serwera uwierzytelniania", "google_enabled": "Asystent Google w\u0142\u0105czony", "logged_in": "Zalogowany", "relayer_connected": "Relayer pod\u0142\u0105czony", diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 2a369fe65e0..27a22dbc5bd 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -173,7 +173,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_records(self, user_input: dict | None = None): """Handle the picking the zone records.""" - errors = {} if user_input is not None: self.cloudflare_config.update(user_input) @@ -183,7 +182,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="records", data_schema=_records_schema(self.records), - errors=errors, ) async def _async_validate_or_error(self, config): diff --git a/homeassistant/components/cloudflare/translations/es-419.json b/homeassistant/components/cloudflare/translations/es-419.json new file mode 100644 index 00000000000..03b49267d12 --- /dev/null +++ b/homeassistant/components/cloudflare/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "description": "Vuelva a autenticarse con su cuenta de Cloudflare." + } + }, + "records": { + "data": { + "records": "Registros" + }, + "title": "Elegir los registros que desea actualizar" + }, + "user": { + "description": "Esta integraci\u00f3n requiere un token de API creado con Zone: Zone: Read y Zone: DNS: Edit permisos para todas las zonas de su cuenta.", + "title": "Conectarse a Cloudflare" + }, + "zone": { + "data": { + "zone": "Zona" + }, + "title": "Elija la zona para actualizar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/nl.json b/homeassistant/components/cloudflare/translations/nl.json index 517743be9aa..5a1bf188a29 100644 --- a/homeassistant/components/cloudflare/translations/nl.json +++ b/homeassistant/components/cloudflare/translations/nl.json @@ -28,7 +28,7 @@ "data": { "api_token": "API-token" }, - "description": "Voor deze integratie is een API-token vereist dat is gemaakt met Zone:Zone:Lezen en Zone:DNS:Bewerk machtigingen voor alle zones in uw account.", + "description": "Voor deze integratie is een API-token vereist dat is gemaakt met Zone:Zone:Read en Zone:DNS:Edit machtigingen voor alle zones in uw account.", "title": "Verbinden met Cloudflare" }, "zone": { diff --git a/homeassistant/components/cloudflare/translations/zh-Hans.json b/homeassistant/components/cloudflare/translations/zh-Hans.json index 4b0a696e5fc..78429184bad 100644 --- a/homeassistant/components/cloudflare/translations/zh-Hans.json +++ b/homeassistant/components/cloudflare/translations/zh-Hans.json @@ -5,6 +5,11 @@ "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548" }, "step": { + "reauth_confirm": { + "data": { + "description": "\u4f7f\u7528\u60a8\u7684 Cloudflare \u5e10\u6237\u91cd\u65b0\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1\u3002" + } + }, "user": { "data": { "api_token": "API \u5bc6\u7801" diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index ea1cd1f6169..a4c1062e2c6 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -123,12 +123,12 @@ class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorE ) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return sensor state.""" return round(self.coordinator.data["data"][self._description.key], 2) # type: ignore[misc] @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if self._description.unit_of_measurement: return self._description.unit_of_measurement diff --git a/homeassistant/components/co2signal/translations/es-419.json b/homeassistant/components/co2signal/translations/es-419.json new file mode 100644 index 00000000000..023c867ee9b --- /dev/null +++ b/homeassistant/components/co2signal/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "country": { + "data": { + "country_code": "C\u00f3digo de pa\u00eds" + } + }, + "user": { + "data": { + "location": "Obtener datos para" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/es.json b/homeassistant/components/co2signal/translations/es.json new file mode 100644 index 00000000000..071ae642c74 --- /dev/null +++ b/homeassistant/components/co2signal/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "api_ratelimit": "Se ha superado el l\u00edmite de velocidad de la API" + }, + "step": { + "country": { + "data": { + "country_code": "C\u00f3digo del pa\u00eds" + } + }, + "user": { + "data": { + "location": "Obtener datos para" + }, + "description": "Visite https://co2signal.com/ para solicitar un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/hu.json b/homeassistant/components/co2signal/translations/hu.json new file mode 100644 index 00000000000..00bc19e7b49 --- /dev/null +++ b/homeassistant/components/co2signal/translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "api_ratelimit": "API D\u00edjkorl\u00e1t t\u00fall\u00e9pve", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "api_ratelimit": "API D\u00edjkorl\u00e1t t\u00fall\u00e9pve", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + }, + "country": { + "data": { + "country_code": "Orsz\u00e1g k\u00f3d" + } + }, + "user": { + "data": { + "api_key": "Hozz\u00e1f\u00e9r\u00e9si token", + "location": "Adatok lek\u00e9rdez\u00e9se a" + }, + "description": "Token k\u00e9r\u00e9s\u00e9hez l\u00e1togasson el a https://co2signal.com/ webhelyre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/no.json b/homeassistant/components/co2signal/translations/no.json new file mode 100644 index 00000000000..bb56f0c1364 --- /dev/null +++ b/homeassistant/components/co2signal/translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "api_ratelimit": "API Ratelimit overskredet", + "unknown": "Uventet feil" + }, + "error": { + "api_ratelimit": "API Ratelimit overskredet", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + } + }, + "country": { + "data": { + "country_code": "Landskode" + } + }, + "user": { + "data": { + "api_key": "Tilgangstoken", + "location": "Hent data for" + }, + "description": "Bes\u00f8k https://co2signal.com/ for \u00e5 be om et token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/zh-Hans.json b/homeassistant/components/co2signal/translations/zh-Hans.json new file mode 100644 index 00000000000..af750541de5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "\u8bbf\u95ee\u4ee4\u724c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 4ea36dad266..5901aeeed9a 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -14,6 +14,8 @@ from . import get_accounts from .const import ( API_ACCOUNT_CURRENCY, API_RATES, + API_RESOURCE_TYPE, + API_TYPE_VAULT, CONF_CURRENCIES, CONF_EXCHANGE_BASE, CONF_EXCHANGE_RATES, @@ -65,7 +67,11 @@ async def validate_options( accounts = await hass.async_add_executor_job(get_accounts, client) - accounts_currencies = [account[API_ACCOUNT_CURRENCY] for account in accounts] + accounts_currencies = [ + account[API_ACCOUNT_CURRENCY] + for account in accounts + if account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ] available_rates = await hass.async_add_executor_job(client.get_exchange_rates) if CONF_CURRENCIES in options: for currency in options[CONF_CURRENCIES]: diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index a7ed0b15986..dc2922d1531 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -18,6 +18,8 @@ API_ACCOUNT_NATIVE_BALANCE = "native_balance" API_ACCOUNT_NAME = "name" API_ACCOUNTS_DATA = "data" API_RATES = "rates" +API_RESOURCE_TYPE = "type" +API_TYPE_VAULT = "vault" WALLETS = { "1INCH": "1INCH", diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index c86f21bac1d..d5abb7d66f5 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -12,6 +12,8 @@ from .const import ( API_ACCOUNT_NAME, API_ACCOUNT_NATIVE_BALANCE, API_RATES, + API_RESOURCE_TYPE, + API_TYPE_VAULT, CONF_CURRENCIES, CONF_EXCHANGE_RATES, DOMAIN, @@ -41,7 +43,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] provided_currencies = [ - account[API_ACCOUNT_CURRENCY] for account in instance.accounts + account[API_ACCOUNT_CURRENCY] + for account in instance.accounts + if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] desired_currencies = [] @@ -82,7 +86,10 @@ class AccountSensor(SensorEntity): self._coinbase_data = coinbase_data self._currency = currency for account in coinbase_data.accounts: - if account[API_ACCOUNT_CURRENCY] == currency: + if ( + account[API_ACCOUNT_CURRENCY] == currency + and account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ): self._name = f"Coinbase {account[API_ACCOUNT_NAME]}" self._id = ( f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" @@ -109,12 +116,12 @@ class AccountSensor(SensorEntity): return self._id @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement @@ -135,7 +142,10 @@ class AccountSensor(SensorEntity): """Get the latest state of the sensor.""" self._coinbase_data.update() for account in self._coinbase_data.accounts: - if account[API_ACCOUNT_CURRENCY] == self._currency: + if ( + account[API_ACCOUNT_CURRENCY] == self._currency + and account[API_RESOURCE_TYPE] != API_TYPE_VAULT + ): self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ API_ACCOUNT_AMOUNT @@ -171,12 +181,12 @@ class ExchangeRateSensor(SensorEntity): return self._id @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/coinbase/translations/cs.json b/homeassistant/components/coinbase/translations/cs.json index 24dc9ec4e14..c6f6a1f36f9 100644 --- a/homeassistant/components/coinbase/translations/cs.json +++ b/homeassistant/components/coinbase/translations/cs.json @@ -15,5 +15,17 @@ } } } + }, + "options": { + "error": { + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "init": { + "data": { + "exchange_base": "Z\u00e1kladn\u00ed m\u011bna pro senzory sm\u011bnn\u00fdch kurz\u016f." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/es-419.json b/homeassistant/components/coinbase/translations/es-419.json new file mode 100644 index 00000000000..12acea8a7df --- /dev/null +++ b/homeassistant/components/coinbase/translations/es-419.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "Secreto de la API", + "exchange_rates": "Tipos de cambio" + }, + "description": "Ingrese los detalles de su clave API proporcionada por Coinbase.", + "title": "Detalles clave de la API de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/no.json b/homeassistant/components/coinbase/translations/no.json index 747049fbd5c..78cf46d717a 100644 --- a/homeassistant/components/coinbase/translations/no.json +++ b/homeassistant/components/coinbase/translations/no.json @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "Lommeboksaldoer som skal rapporteres.", + "exchange_base": "Standardvaluta for valutakurssensorer.", "exchange_rate_currencies": "Valutakurser som skal rapporteres." }, "description": "Juster Coinbase-alternativer" diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 5d4ec6eec13..fc038adc568 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -1,4 +1,6 @@ """Support for ComEd Hourly Pricing data.""" +from __future__ import annotations + import asyncio from datetime import timedelta import json @@ -8,7 +10,11 @@ import aiohttp import async_timeout 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_NAME, CONF_OFFSET from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -25,12 +31,22 @@ CONF_FIVE_MINUTE = "five_minute" CONF_MONITORED_FEEDS = "monitored_feeds" CONF_SENSOR_TYPE = "type" -SENSOR_TYPES = { - CONF_FIVE_MINUTE: ["ComEd 5 Minute Price", "c"], - CONF_CURRENT_HOUR_AVERAGE: ["ComEd Current Hour Average Price", "c"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=CONF_FIVE_MINUTE, + name="ComEd 5 Minute Price", + native_unit_of_measurement="c", + ), + SensorEntityDescription( + key=CONF_CURRENT_HOUR_AVERAGE, + name="ComEd Current Hour Average Price", + native_unit_of_measurement="c", + ), +) -TYPES_SCHEMA = vol.In(SENSOR_TYPES) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + +TYPES_SCHEMA = vol.In(SENSOR_KEYS) SENSORS_SCHEMA = vol.Schema( { @@ -48,64 +64,42 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the ComEd Hourly Pricing sensor.""" websession = async_get_clientsession(hass) - dev = [] - for variable in config[CONF_MONITORED_FEEDS]: - dev.append( - ComedHourlyPricingSensor( - hass.loop, - websession, - variable[CONF_SENSOR_TYPE], - variable[CONF_OFFSET], - variable.get(CONF_NAME), - ) + entities = [ + ComedHourlyPricingSensor( + websession, + variable[CONF_OFFSET], + variable.get(CONF_NAME), + description, ) + for variable in config[CONF_MONITORED_FEEDS] + for description in SENSOR_TYPES + if description.key == variable[CONF_SENSOR_TYPE] + ] - async_add_entities(dev, True) + async_add_entities(entities, True) class ComedHourlyPricingSensor(SensorEntity): """Implementation of a ComEd Hourly Pricing sensor.""" - def __init__(self, loop, websession, sensor_type, offset, name): + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + def __init__(self, websession, offset, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.loop = loop + self.entity_description = description self.websession = websession if name: - self._name = name - else: - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type + self._attr_name = name self.offset = offset - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} async def async_update(self): """Get the ComEd Hourly Pricing data from the web service.""" try: - if self.type == CONF_FIVE_MINUTE or self.type == CONF_CURRENT_HOUR_AVERAGE: + sensor_type = self.entity_description.key + if sensor_type in (CONF_FIVE_MINUTE, CONF_CURRENT_HOUR_AVERAGE): url_string = _RESOURCE - if self.type == CONF_FIVE_MINUTE: + if sensor_type == CONF_FIVE_MINUTE: url_string += "?type=5minutefeed" else: url_string += "?type=currenthouraverage" @@ -115,10 +109,12 @@ class ComedHourlyPricingSensor(SensorEntity): # The API responds with MIME type 'text/html' text = await response.text() data = json.loads(text) - self._state = round(float(data[0]["price"]) + self.offset, 2) + self._attr_native_value = round( + float(data[0]["price"]) + self.offset, 2 + ) else: - self._state = None + self._attr_native_value = None except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Could not get data from ComEd API: %s", err) diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 728bc13b76b..a6a625bab99 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -297,7 +297,7 @@ class ComfoConnectSensor(SensorEntity): self.schedule_update_ha_state() @property - def state(self): + def native_value(self): """Return the state of the entity.""" try: return self._ccb.data[self._sensor_id] @@ -325,7 +325,7 @@ class ComfoConnectSensor(SensorEntity): return SENSOR_TYPES[self._sensor_type][ATTR_ICON] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity.""" return SENSOR_TYPES[self._sensor_type][ATTR_UNIT] diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 10c5a16f60b..43e05a429b6 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -84,12 +84,12 @@ class CommandSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 35ca07ce522..257c6b4a354 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -107,7 +107,7 @@ class CompensationSensor(SensorEntity): return False @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -123,7 +123,7 @@ class CompensationSensor(SensorEntity): return ret @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index d9029dc497f..a6b39e556aa 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -44,8 +44,8 @@ class CheckConfigView(HomeAssistantView): vol.Optional("unit_system"): cv.unit_system, vol.Optional("location_name"): str, vol.Optional("time_zone"): cv.time_zone, - vol.Optional("external_url"): vol.Any(cv.url, None), - vol.Optional("internal_url"): vol.Any(cv.url, None), + vol.Optional("external_url"): vol.Any(cv.url_no_path, None), + vol.Optional("internal_url"): vol.Any(cv.url_no_path, None), vol.Optional("currency"): cv.currency, } ) diff --git a/homeassistant/components/control4/translations/hu.json b/homeassistant/components/control4/translations/hu.json index 68cb4fe23a9..5d41eb09a84 100644 --- a/homeassistant/components/control4/translations/hu.json +++ b/homeassistant/components/control4/translations/hu.json @@ -14,6 +14,16 @@ "host": "IP c\u00edm", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rj\u00fck, adja meg Control4-fi\u00f3kj\u00e1nak adatait \u00e9s a helyi vez\u00e9rl\u0151 IP-c\u00edm\u00e9t." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Friss\u00edt\u00e9sek k\u00f6z\u00f6tti m\u00e1sodpercek" } } } diff --git a/homeassistant/components/coolmaster/translations/es-419.json b/homeassistant/components/coolmaster/translations/es-419.json index 9073238aa91..0c41a0dbfe5 100644 --- a/homeassistant/components/coolmaster/translations/es-419.json +++ b/homeassistant/components/coolmaster/translations/es-419.json @@ -6,6 +6,11 @@ "step": { "user": { "data": { + "cool": "Soporta el modo de enfriamiento", + "dry": "Soporta el modo seco", + "fan_only": "Soporta el modo solo ventilador", + "heat": "Soporta el modo de calor", + "heat_cool": "Soporta el modo autom\u00e1tico de calor/fr\u00edo", "host": "Host", "off": "Puede ser apagado" }, diff --git a/homeassistant/components/coolmaster/translations/hu.json b/homeassistant/components/coolmaster/translations/hu.json index bf67763ca6b..d52dba6b4b4 100644 --- a/homeassistant/components/coolmaster/translations/hu.json +++ b/homeassistant/components/coolmaster/translations/hu.json @@ -1,14 +1,21 @@ { "config": { "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "no_units": "Nem tal\u00e1lhat\u00f3 HVAC egys\u00e9g a CoolMasterNet gazdag\u00e9pben." }, "step": { "user": { "data": { + "cool": "T\u00e1mogatott a h\u0171t\u00e9si m\u00f3d(ok)", + "dry": "T\u00e1mogassa a p\u00e1r\u00e1tlan\u00edt\u00f3 m\u00f3d(ok)", + "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", "off": "Ki lehet kapcsolni" - } + }, + "title": "\u00c1ll\u00edtsa be a CoolMasterNet kapcsolat r\u00e9szleteit." } } } diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index c855137fcbf..d130e131c8b 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -8,13 +8,14 @@ import coronavirus from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, entity_registry, update_coordinator +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN PLATFORMS = ["sensor"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Coronavirus component.""" # Make sure coordinator is initialized. await get_coordinator(hass) diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py index b467a5fee12..92fdf232214 100644 --- a/homeassistant/components/coronavirus/sensor.py +++ b/homeassistant/components/coronavirus/sensor.py @@ -27,7 +27,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class CoronavirusSensor(CoordinatorEntity, SensorEntity): """Sensor representing corona virus data.""" - _attr_unit_of_measurement = "people" + _attr_native_unit_of_measurement = "people" def __init__(self, coordinator, country, info_type): """Initialize coronavirus sensor.""" @@ -53,7 +53,7 @@ class CoronavirusSensor(CoordinatorEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """State of the sensor.""" if self.country == OPTION_WORLDWIDE: sum_cases = 0 diff --git a/homeassistant/components/coronavirus/translations/zh-Hans.json b/homeassistant/components/coronavirus/translations/zh-Hans.json index 5bb92ac1172..6348ac40896 100644 --- a/homeassistant/components/coronavirus/translations/zh-Hans.json +++ b/homeassistant/components/coronavirus/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002" + "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "step": { "user": { diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index 13ef4523f5b..debb2368cf2 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -21,6 +21,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features +from homeassistant.helpers.typing import ConfigType from . import ( ATTR_POSITION, @@ -58,7 +59,9 @@ POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Cover devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] @@ -98,7 +101,9 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: return actions -async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List action capabilities.""" if config[CONF_TYPE] not in POSITION_ACTION_TYPES: return {} diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index bd433dbd93d..c163bd097ae 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -1,8 +1,6 @@ """Provides device automations for Cover.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( @@ -38,6 +36,8 @@ from . import ( SUPPORT_SET_TILT_POSITION, ) +# mypy: disallow-any-generics + POSITION_CONDITION_TYPES = {"is_position", "is_tilt_position"} STATE_CONDITION_TYPES = {"is_open", "is_closed", "is_opening", "is_closing"} @@ -67,10 +67,12 @@ STATE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( CONDITION_SCHEMA = vol.Any(POSITION_CONDITION_SCHEMA, STATE_CONDITION_SCHEMA) -async def async_get_conditions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device conditions for Cover devices.""" registry = await entity_registry.async_get_registry(hass) - conditions: list[dict[str, Any]] = [] + conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device for entry in entity_registry.async_entries_for_device(registry, device_id): @@ -100,7 +102,9 @@ async def async_get_conditions(hass: HomeAssistant, device_id: str) -> list[dict return conditions -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" if config[CONF_TYPE] not in ["is_position", "is_tilt_position"]: return {} diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index acfd276d1fb..e7048032cba 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Cover.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -67,7 +69,9 @@ STATE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( TRIGGER_SCHEMA = vol.Any(POSITION_TRIGGER_SCHEMA, STATE_TRIGGER_SCHEMA) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Cover devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -114,7 +118,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" if config[CONF_TYPE] not in POSITION_TRIGGER_TYPES: return { diff --git a/homeassistant/components/cover/translations/es-419.json b/homeassistant/components/cover/translations/es-419.json index c6f9f7db7dd..d2a1aebaa1d 100644 --- a/homeassistant/components/cover/translations/es-419.json +++ b/homeassistant/components/cover/translations/es-419.json @@ -6,7 +6,8 @@ "open": "Abrir {entity_name}", "open_tilt": "Abrir la inclinaci\u00f3n de {entity_name}", "set_position": "Establecer la posici\u00f3n de {entity_name}", - "set_tilt_position": "Establecer la posici\u00f3n de inclinaci\u00f3n {entity_name}" + "set_tilt_position": "Establecer la posici\u00f3n de inclinaci\u00f3n {entity_name}", + "stop": "Detener {entity_name}" }, "condition_type": { "is_closed": "{entity_name} est\u00e1 cerrado", diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index 01938344694..c34ea939de7 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -43,12 +43,12 @@ class CpuSpeedSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return FREQUENCY_GIGAHERTZ diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index 6a3fc7b4215..74d3d9a36a2 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -111,7 +111,7 @@ class CupsSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._printer is None: return None @@ -183,7 +183,7 @@ class IPPSensor(SensorEntity): return self._available @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._attributes is None: return None @@ -257,7 +257,7 @@ class MarkerSensor(SensorEntity): return ICON_MARKER @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._attributes is None: return None @@ -265,7 +265,7 @@ class MarkerSensor(SensorEntity): return self._attributes[self._printer]["marker-levels"][self._index] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index f42534f509b..fd3f3b2f8c5 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -65,7 +65,7 @@ class CurrencylayerSensor(SensorEntity): self._state = None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._quote @@ -80,7 +80,7 @@ class CurrencylayerSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 3bfc0a3926c..0defa633387 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -86,7 +86,7 @@ class DaikinSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" raise NotImplementedError @@ -101,7 +101,7 @@ class DaikinSensor(SensorEntity): return self._sensor.get(CONF_ICON) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._sensor[CONF_UNIT_OF_MEASUREMENT] @@ -119,7 +119,7 @@ class DaikinClimateSensor(DaikinSensor): """Representation of a Climate Sensor.""" @property - def state(self): + def native_value(self): """Return the internal state of the sensor.""" if self._device_attribute == ATTR_INSIDE_TEMPERATURE: return self._api.device.inside_temperature @@ -141,7 +141,7 @@ class DaikinPowerSensor(DaikinSensor): """Representation of a power/energy consumption sensor.""" @property - def state(self): + 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) diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index 792a95e8ac4..264e69739af 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -3,7 +3,7 @@ import logging from pydanfossair.commands import ReadCommand -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, @@ -27,52 +27,73 @@ def setup_platform(hass, config, add_entities, discovery_info=None): TEMP_CELSIUS, ReadCommand.exhaustTemperature, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ], [ "Danfoss Air Outdoor Temperature", TEMP_CELSIUS, ReadCommand.outdoorTemperature, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ], [ "Danfoss Air Supply Temperature", TEMP_CELSIUS, ReadCommand.supplyTemperature, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ], [ "Danfoss Air Extract Temperature", TEMP_CELSIUS, ReadCommand.extractTemperature, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ], [ "Danfoss Air Remaining Filter", PERCENTAGE, ReadCommand.filterPercent, None, + None, ], [ "Danfoss Air Humidity", PERCENTAGE, ReadCommand.humidity, DEVICE_CLASS_HUMIDITY, + STATE_CLASS_MEASUREMENT, + ], + ["Danfoss Air Fan Step", PERCENTAGE, ReadCommand.fan_step, None, None], + [ + "Danfoss Air Exhaust Fan Speed", + "RPM", + ReadCommand.exhaust_fan_speed, + None, + None, + ], + [ + "Danfoss Air Supply Fan Speed", + "RPM", + ReadCommand.supply_fan_speed, + None, + None, ], - ["Danfoss Air Fan Step", PERCENTAGE, ReadCommand.fan_step, None], - ["Danfoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None], - ["Danfoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None], [ "Danfoss Air Dial Battery", PERCENTAGE, ReadCommand.battery_percent, DEVICE_CLASS_BATTERY, + None, ], ] dev = [] for sensor in sensors: - dev.append(DanfossAir(data, sensor[0], sensor[1], sensor[2], sensor[3])) + dev.append( + DanfossAir(data, sensor[0], sensor[1], sensor[2], sensor[3], sensor[4]) + ) add_entities(dev, True) @@ -80,7 +101,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DanfossAir(SensorEntity): """Representation of a Sensor.""" - def __init__(self, data, name, sensor_unit, sensor_type, device_class): + def __init__(self, data, name, sensor_unit, sensor_type, device_class, state_class): """Initialize the sensor.""" self._data = data self._name = name @@ -88,6 +109,7 @@ class DanfossAir(SensorEntity): self._type = sensor_type self._unit = sensor_unit self._device_class = device_class + self._attr_state_class = state_class @property def name(self): @@ -100,12 +122,12 @@ class DanfossAir(SensorEntity): return self._device_class @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 058969d96f9..e73d9b2e1be 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -574,12 +574,12 @@ class DarkSkySensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @@ -730,7 +730,7 @@ class DarkSkyAlertSensor(SensorEntity): return f"{self.client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index a67d7181a90..3ff5d087e14 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.0"], + "requirements": ["debugpy==1.4.1"], "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 4fc3e2ad0b8..c0ebc9d3134 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -5,13 +5,19 @@ from pydeconz.sensor import ( ANCILLARY_CONTROL_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT, ANCILLARY_CONTROL_ARMED_STAY, + ANCILLARY_CONTROL_ARMING_AWAY, + ANCILLARY_CONTROL_ARMING_NIGHT, + ANCILLARY_CONTROL_ARMING_STAY, ANCILLARY_CONTROL_DISARMED, + ANCILLARY_CONTROL_ENTRY_DELAY, + ANCILLARY_CONTROL_EXIT_DELAY, + ANCILLARY_CONTROL_IN_ALARM, AncillaryControl, ) -import voluptuous as vol from homeassistant.components.alarm_control_panel import ( DOMAIN, + FORMAT_NUMBER, SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, @@ -21,40 +27,39 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, ) from homeassistant.core import callback -from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import NEW_SENSOR from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -PANEL_ENTRY_DELAY = "entry_delay" -PANEL_EXIT_DELAY = "exit_delay" -PANEL_NOT_READY_TO_ARM = "not_ready_to_arm" - -SERVICE_ALARM_PANEL_STATE = "alarm_panel_state" -CONF_ALARM_PANEL_STATE = "panel_state" -SERVICE_ALARM_PANEL_STATE_SCHEMA = { - vol.Required(CONF_ALARM_PANEL_STATE): vol.In( - [ - PANEL_ENTRY_DELAY, - PANEL_EXIT_DELAY, - PANEL_NOT_READY_TO_ARM, - ] - ) -} - DECONZ_TO_ALARM_STATE = { ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, ANCILLARY_CONTROL_ARMED_STAY: STATE_ALARM_ARMED_HOME, + ANCILLARY_CONTROL_ARMING_AWAY: STATE_ALARM_ARMING, + ANCILLARY_CONTROL_ARMING_NIGHT: STATE_ALARM_ARMING, + ANCILLARY_CONTROL_ARMING_STAY: STATE_ALARM_ARMING, ANCILLARY_CONTROL_DISARMED: STATE_ALARM_DISARMED, + ANCILLARY_CONTROL_ENTRY_DELAY: STATE_ALARM_PENDING, + ANCILLARY_CONTROL_EXIT_DELAY: STATE_ALARM_PENDING, + ANCILLARY_CONTROL_IN_ALARM: STATE_ALARM_TRIGGERED, } +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(): + if unique_id in alarm_system.devices: + return alarm_system + + async def async_setup_entry(hass, config_entry, async_add_entities) -> None: """Set up the deCONZ alarm control panel devices. @@ -63,8 +68,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() - platform = entity_platform.async_get_current_platform() - @callback def async_add_alarm_control_panel(sensors=gateway.api.sensors.values()) -> None: """Add alarm control panel devices from deCONZ.""" @@ -75,15 +78,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None: if ( sensor.type in AncillaryControl.ZHATYPE and sensor.uniqueid not in gateway.entities[DOMAIN] + and get_alarm_system_for_unique_id(gateway, sensor.uniqueid) ): + entities.append(DeconzAlarmControlPanel(sensor, gateway)) if entities: - platform.async_register_entity_service( - SERVICE_ALARM_PANEL_STATE, - SERVICE_ALARM_PANEL_STATE_SCHEMA, - "async_set_panel_state", - ) async_add_entities(entities) config_entry.async_on_unload( @@ -102,7 +102,7 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): TYPE = DOMAIN - _attr_code_arm_required = False + _attr_code_format = FORMAT_NUMBER _attr_supported_features = ( SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_NIGHT ) @@ -110,16 +110,12 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): def __init__(self, device, gateway) -> None: """Set up alarm control panel device.""" super().__init__(device, gateway) - self._service_to_device_panel_command = { - PANEL_ENTRY_DELAY: self._device.entry_delay, - PANEL_EXIT_DELAY: self._device.exit_delay, - PANEL_NOT_READY_TO_ARM: self._device.not_ready_to_arm, - } + self.alarm_system = get_alarm_system_for_unique_id(gateway, device.uniqueid) @callback def async_update_callback(self, force_update: bool = False) -> None: """Update the control panels state.""" - keys = {"armed", "reachable"} + keys = {"panel", "reachable"} if force_update or ( self._device.changed_keys.intersection(keys) and self._device.state in DECONZ_TO_ALARM_STATE @@ -133,20 +129,16 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): async def async_alarm_arm_away(self, code: None = None) -> None: """Send arm away command.""" - await self._device.arm_away() + await self.alarm_system.arm_away(code) async def async_alarm_arm_home(self, code: None = None) -> None: """Send arm home command.""" - await self._device.arm_stay() + await self.alarm_system.arm_stay(code) async def async_alarm_arm_night(self, code: None = None) -> None: """Send arm night command.""" - await self._device.arm_night() + await self.alarm_system.arm_night(code) async def async_alarm_disarm(self, code: None = None) -> None: """Send disarm command.""" - await self._device.disarm() - - async def async_set_panel_state(self, panel_state: str) -> None: - """Send panel_state command.""" - await self._service_to_device_panel_command[panel_state]() + await self.alarm_system.disarm(code) diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index c5336825878..2fa9ec87fe3 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -1,25 +1,20 @@ """Representation of a deCONZ remote or keypad.""" from pydeconz.sensor import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY, - ANCILLARY_CONTROL_DISARMED, + ANCILLARY_CONTROL_EMERGENCY, + ANCILLARY_CONTROL_FIRE, + ANCILLARY_CONTROL_INVALID_CODE, + ANCILLARY_CONTROL_PANIC, AncillaryControl, Switch, ) from homeassistant.const import ( - CONF_CODE, CONF_DEVICE_ID, CONF_EVENT, CONF_ID, CONF_UNIQUE_ID, CONF_XY, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -31,11 +26,11 @@ from .deconz_device import DeconzBase CONF_DECONZ_EVENT = "deconz_event" CONF_DECONZ_ALARM_EVENT = "deconz_alarm_event" -DECONZ_TO_ALARM_STATE = { - ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY: STATE_ALARM_ARMED_HOME, - ANCILLARY_CONTROL_DISARMED: STATE_ALARM_DISARMED, +SUPPORTED_DECONZ_ALARM_EVENTS = { + ANCILLARY_CONTROL_EMERGENCY, + ANCILLARY_CONTROL_FIRE, + ANCILLARY_CONTROL_INVALID_CODE, + ANCILLARY_CONTROL_PANIC, } @@ -155,31 +150,23 @@ class DeconzEvent(DeconzBase): class DeconzAlarmEvent(DeconzEvent): - """Alarm control panel companion event when user inputs a code.""" + """Alarm control panel companion event when user interacts with a keypad.""" @callback def async_update_callback(self, force_update=False): - """Fire the event if reason is that state is updated.""" + """Fire the event if reason is new action is updated.""" if ( self.gateway.ignore_state_updates or "action" not in self._device.changed_keys + or self._device.action not in SUPPORTED_DECONZ_ALARM_EVENTS ): return - try: - state, code, _area = self._device.action.split(",") - except (AttributeError, ValueError): - return - - if state not in DECONZ_TO_ALARM_STATE: - return - data = { CONF_ID: self.event_id, CONF_UNIQUE_ID: self.serial, CONF_DEVICE_ID: self.device_id, - CONF_EVENT: DECONZ_TO_ALARM_STATE[state], - CONF_CODE: code, + CONF_EVENT: self._device.action, } self.gateway.hass.bus.async_fire(CONF_DECONZ_ALARM_EVENT, data) diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 5beaba2c5a5..8234ed81aed 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -516,6 +516,14 @@ BUSCH_JAEGER_REMOTE = { (CONF_LONG_RELEASE, CONF_BUTTON_8): {CONF_EVENT: 8003}, } +SONOFF_SNZB_01_1_MODEL = "WB01" +SONOFF_SNZB_01_2_MODEL = "WB-01" +SONOFF_SNZB_01_SWITCH = { + (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1003}, + (CONF_DOUBLE_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1004}, +} + TRUST_ZYCT_202_MODEL = "ZYCT-202" TRUST_ZYCT_202_ZLL_MODEL = "ZLL-NonColorController" TRUST_ZYCT_202 = { @@ -595,6 +603,8 @@ REMOTES = { TRUST_ZYCT_202_ZLL_MODEL: TRUST_ZYCT_202, UBISYS_POWER_SWITCH_S2_MODEL: UBISYS_POWER_SWITCH_S2, UBISYS_CONTROL_UNIT_C4_MODEL: UBISYS_CONTROL_UNIT_C4, + SONOFF_SNZB_01_1_MODEL: SONOFF_SNZB_01_SWITCH, + SONOFF_SNZB_01_2_MODEL: SONOFF_SNZB_01_SWITCH, } TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 0ae9e8c98b0..69deac8b5e0 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==82" + "pydeconz==83" ], "ssdp": [ { diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 9282f2d26cc..012e686534f 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -17,6 +17,7 @@ from pydeconz.sensor import ( from homeassistant.components.sensor import ( DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -41,7 +42,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.util import dt as dt_util from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice @@ -68,7 +68,7 @@ ICON = { } STATE_CLASS = { - Consumption: STATE_CLASS_MEASUREMENT, + Consumption: STATE_CLASS_TOTAL_INCREASING, Humidity: STATE_CLASS_MEASUREMENT, Pressure: STATE_CLASS_MEASUREMENT, Temperature: STATE_CLASS_MEASUREMENT, @@ -160,10 +160,9 @@ class DeconzSensor(DeconzDevice, SensorEntity): 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_unit_of_measurement = UNIT_OF_MEASUREMENT.get(type(self._device)) - - if device.type in Consumption.ZHATYPE: - self._attr_last_reset = dt_util.utc_from_timestamp(0) + self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT.get( + type(self._device) + ) @callback def async_update_callback(self, force_update=False): @@ -173,7 +172,7 @@ class DeconzSensor(DeconzDevice, SensorEntity): super().async_update_callback(force_update=force_update) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.state @@ -217,7 +216,7 @@ class DeconzTemperature(DeconzDevice, SensorEntity): _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS TYPE = DOMAIN @@ -240,7 +239,7 @@ class DeconzTemperature(DeconzDevice, SensorEntity): super().async_update_callback(force_update=force_update) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.secondary_temperature @@ -250,7 +249,7 @@ class DeconzBattery(DeconzDevice, SensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE TYPE = DOMAIN @@ -284,7 +283,7 @@ class DeconzBattery(DeconzDevice, SensorEntity): return f"{self.serial}-battery" @property - def state(self): + def native_value(self): """Return the state of the battery.""" return self._device.battery diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index fbaf47b009c..9084728a216 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -63,25 +63,3 @@ remove_orphaned_entries: example: "00212EFFFF012345" selector: text: - -alarm_panel_state: - name: Alarm panel state - description: Put keypad panel in an intermediate state, to help with visual and audible cues to the user. - target: - entity: - integration: deconz - domain: alarm_control_panel - fields: - panel_state: - name: Panel state - description: >- - - "entry_delay": make panel beep until panel is disarmed. Beep interval is long. - - "exit_delay": make panel beep until panel is set to armed state. Beep interval is short. - - "not_ready_to_arm": turn on yellow status led on the panel. Indicate not all conditions for arming are met. - required: true - selector: - select: - options: - - "entry_delay" - - "exit_delay" - - "not_ready_to_arm" diff --git a/homeassistant/components/deconz/translations/da.json b/homeassistant/components/deconz/translations/da.json index be165a206bf..00e054aecc9 100644 --- a/homeassistant/components/deconz/translations/da.json +++ b/homeassistant/components/deconz/translations/da.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Bridge er allerede konfigureret", "already_in_progress": "Konfigurationsflow for bro er allerede i gang.", - "no_bridges": "Ingen deConz-bridge fundet", + "no_bridges": "Ingen deConz-bro fundet", "not_deconz_bridge": "Ikke en deCONZ-bro", "updated_instance": "Opdaterede deCONZ-instans med ny v\u00e6rtadresse" }, diff --git a/homeassistant/components/deconz/translations/es-419.json b/homeassistant/components/deconz/translations/es-419.json index e439d1da949..ceb0ca39d2c 100644 --- a/homeassistant/components/deconz/translations/es-419.json +++ b/homeassistant/components/deconz/translations/es-419.json @@ -4,6 +4,7 @@ "already_configured": "El Bridge ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en progreso.", "no_bridges": "No se descubrieron puentes deCONZ", + "no_hardware_available": "No hay hardware de radio conectado a deCONZ", "not_deconz_bridge": "No es un puente deCONZ", "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host" }, @@ -41,6 +42,10 @@ "button_2": "Segundo bot\u00f3n", "button_3": "Tercer bot\u00f3n", "button_4": "Cuarto bot\u00f3n", + "button_5": "Quinto bot\u00f3n", + "button_6": "Sexto bot\u00f3n", + "button_7": "S\u00e9ptimo bot\u00f3n", + "button_8": "Octavo bot\u00f3n", "close": "Cerrar", "dim_down": "Bajar la intensidad", "dim_up": "Aumentar intensidad", @@ -65,6 +70,7 @@ "remote_button_quadruple_press": "El bot\u00f3n \"{subtype}\" ha sido pulsado 4 veces", "remote_button_quintuple_press": "El bot\u00f3n \"{subtype}\" ha sido pulsado 5 veces", "remote_button_rotated": "Bot\u00f3n girado \"{subtype}\"", + "remote_button_rotated_fast": "Bot\u00f3n girado r\u00e1pidamente \"{subtype}\"", "remote_button_rotation_stopped": "Se detuvo la rotaci\u00f3n del bot\u00f3n \"{subtype}\"", "remote_button_short_press": "Se presion\u00f3 el bot\u00f3n \"{subtype}\"", "remote_button_short_release": "Se solt\u00f3 el bot\u00f3n \"{subtype}\"", @@ -92,7 +98,8 @@ "deconz_devices": { "data": { "allow_clip_sensor": "Permitir sensores deCONZ CLIP", - "allow_deconz_groups": "Permitir grupos de luz deCONZ" + "allow_deconz_groups": "Permitir grupos de luz deCONZ", + "allow_new_devices": "Permitir la adici\u00f3n autom\u00e1tica de nuevos dispositivos" }, "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ", "title": "Opciones de deCONZ" diff --git a/homeassistant/components/deconz/translations/hu.json b/homeassistant/components/deconz/translations/hu.json index 84493ccb9f6..bc003a279e8 100644 --- a/homeassistant/components/deconz/translations/hu.json +++ b/homeassistant/components/deconz/translations/hu.json @@ -26,12 +26,18 @@ "host": "Hoszt", "port": "Port" } + }, + "user": { + "data": { + "host": "V\u00e1lassza ki a felfedezett deCONZ \u00e1tj\u00e1r\u00f3t" + } } } }, "device_automation": { "trigger_subtype": { "both_buttons": "Mindk\u00e9t gomb", + "bottom_buttons": "Als\u00f3 gombok", "button_1": "Els\u0151 gomb", "button_2": "M\u00e1sodik gomb", "button_3": "Harmadik gomb", @@ -52,6 +58,7 @@ "side_4": "4. oldal", "side_5": "5. oldal", "side_6": "6. oldal", + "top_buttons": "Fels\u0151 gombok", "turn_off": "Kikapcsolva", "turn_on": "Bekapcsolva" }, @@ -63,6 +70,7 @@ "remote_button_quadruple_press": "\"{subtype}\" gombra n\u00e9gyszer kattintottak", "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_short_press": "\"{subtype}\" gomb lenyomva", "remote_button_short_release": "\"{subtype}\" gomb elengedve", @@ -93,7 +101,8 @@ "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se", "allow_new_devices": "Enged\u00e9lyezze az \u00faj eszk\u00f6z\u00f6k automatikus hozz\u00e1ad\u00e1s\u00e1t" }, - "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa" + "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa", + "title": "deCONZ opci\u00f3k" } } } diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 834438f5a9f..274e53c2f38 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -29,6 +29,7 @@ "system_health", "tag", "timer", + "usb", "updater", "webhook", "zeroconf", diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index b105ff5ff7b..395c2d93dff 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -112,7 +112,7 @@ class DeLijnPublicTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 0c79e6f835e..a5667f35064 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -1,10 +1,16 @@ """Support for monitoring the Deluge BitTorrent client API.""" +from __future__ import annotations + import logging from deluge_client import DelugeRPCClient, FailedToReconnectException import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_VARIABLES, @@ -25,11 +31,24 @@ DEFAULT_NAME = "Deluge" DEFAULT_PORT = 58846 DHT_UPLOAD = 1000 DHT_DOWNLOAD = 1000 -SENSOR_TYPES = { - "current_status": ["Status", None], - "download_speed": ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], - "upload_speed": ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="current_status", + name="Status", + ), + SensorEntityDescription( + key="download_speed", + name="Down Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key="upload_speed", + name="Up Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -39,7 +58,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -60,46 +79,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except ConnectionRefusedError as err: _LOGGER.error("Connection to Deluge Daemon failed") raise PlatformNotReady from err - dev = [] - for variable in config[CONF_MONITORED_VARIABLES]: - dev.append(DelugeSensor(variable, deluge_api, name)) + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + DelugeSensor(deluge_api, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - add_entities(dev) + add_entities(entities) class DelugeSensor(SensorEntity): """Representation of a Deluge sensor.""" - def __init__(self, sensor_type, deluge_client, client_name): + def __init__( + self, deluge_client, client_name, description: SensorEntityDescription + ): """Initialize the sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.client = deluge_client - self.type = sensor_type - self.client_name = client_name - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self.data = None - self._available = False - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return true if device is available.""" - return self._available - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_available = False + self._attr_name = f"{client_name} {description.name}" def update(self): """Get the latest data from Deluge and updates the state.""" @@ -114,34 +116,35 @@ class DelugeSensor(SensorEntity): "dht_download_rate", ], ) - self._available = True + self._attr_available = True except FailedToReconnectException: _LOGGER.error("Connection to Deluge Daemon Lost") - self._available = False + self._attr_available = False return upload = self.data[b"upload_rate"] - self.data[b"dht_upload_rate"] download = self.data[b"download_rate"] - self.data[b"dht_download_rate"] - if self.type == "current_status": + sensor_type = self.entity_description.key + if sensor_type == "current_status": if self.data: if upload > 0 and download > 0: - self._state = "Up/Down" + self._attr_native_value = "Up/Down" elif upload > 0 and download == 0: - self._state = "Seeding" + self._attr_native_value = "Seeding" elif upload == 0 and download > 0: - self._state = "Downloading" + self._attr_native_value = "Downloading" else: - self._state = STATE_IDLE + self._attr_native_value = STATE_IDLE else: - self._state = None + self._attr_native_value = None if self.data: - if self.type == "download_speed": + if sensor_type == "download_speed": kb_spd = float(download) kb_spd = kb_spd / 1024 - self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) - elif self.type == "upload_speed": + self._attr_native_value = round(kb_spd, 2 if kb_spd < 0.1 else 1) + elif sensor_type == "upload_speed": kb_spd = float(upload) kb_spd = kb_spd / 1024 - self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1) + self._attr_native_value = round(kb_spd, 2 if kb_spd < 0.1 else 1) diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 56726bba8b7..572a5bf331e 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -1,4 +1,6 @@ """Demo camera platform that has a fake camera.""" +from __future__ import annotations + from pathlib import Path from homeassistant.components.camera import SUPPORT_ON_OFF, Camera @@ -6,7 +8,12 @@ from homeassistant.components.camera import SUPPORT_ON_OFF, Camera async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Demo camera platform.""" - async_add_entities([DemoCamera("Demo camera")]) + async_add_entities( + [ + DemoCamera("Demo camera", "image/jpg"), + DemoCamera("Demo camera png", "image/png"), + ] + ) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -17,18 +24,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class DemoCamera(Camera): """The representation of a Demo camera.""" - def __init__(self, name): + def __init__(self, name, content_type): """Initialize demo camera component.""" super().__init__() self._name = name + self.content_type = content_type self._motion_status = False self.is_streaming = True self._images_index = 0 - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes: """Return a faked still image response.""" self._images_index = (self._images_index + 1) % 4 - image_path = Path(__file__).parent / f"demo_{self._images_index}.jpg" + ext = "jpg" if self.content_type == "image/jpg" else "png" + image_path = Path(__file__).parent / f"demo_{self._images_index}.{ext}" return await self.hass.async_add_executor_job(image_path.read_bytes) diff --git a/homeassistant/components/demo/demo_0.png b/homeassistant/components/demo/demo_0.png new file mode 100644 index 00000000000..f45852e3b20 Binary files /dev/null and b/homeassistant/components/demo/demo_0.png differ diff --git a/homeassistant/components/demo/demo_1.png b/homeassistant/components/demo/demo_1.png new file mode 100644 index 00000000000..0a2131a773e Binary files /dev/null and b/homeassistant/components/demo/demo_1.png differ diff --git a/homeassistant/components/demo/demo_2.png b/homeassistant/components/demo/demo_2.png new file mode 100644 index 00000000000..97a8e49025d Binary files /dev/null and b/homeassistant/components/demo/demo_2.png differ diff --git a/homeassistant/components/demo/demo_3.png b/homeassistant/components/demo/demo_3.png new file mode 100644 index 00000000000..0a2131a773e Binary files /dev/null and b/homeassistant/components/demo/demo_3.png differ diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 7eabf9bea2d..af61c0f6111 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -97,3 +97,4 @@ class DemoLock(LockEntity): """Flag supported features.""" if self._openable: return SUPPORT_OPEN + return 0 diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index a6842d2ca43..cad2255806e 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -1,8 +1,6 @@ """Demo platform that offers a fake Number entity.""" from __future__ import annotations -import voluptuous as vol - from homeassistant.components.number import NumberEntity from homeassistant.const import DEVICE_DEFAULT_NAME @@ -82,12 +80,5 @@ class DemoNumber(NumberEntity): async def async_set_value(self, value): """Update the current value.""" - num_value = float(value) - - if num_value < self.min_value or num_value > self.max_value: - raise vol.Invalid( - f"Invalid value for {self.entity_id}: {value} (range {self.min_value} - {self.max_value})" - ) - - self._attr_value = num_value + self._attr_value = value self.async_write_ha_state() diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index dcc0c12a9b4..8d499c7a258 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -73,8 +73,5 @@ class DemoSelect(SelectEntity): async def async_select_option(self, option: str) -> None: """Update the current selected option.""" - if option not in self.options: - raise ValueError(f"Invalid option for {self.entity_id}: {option}") - self._attr_current_option = option self.async_write_ha_state() diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 488c34be983..21ec8e1d391 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -120,10 +120,10 @@ class DemoSensor(SensorEntity): """Initialize the sensor.""" self._attr_device_class = device_class self._attr_name = name - self._attr_state = state + self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_native_value = state self._attr_state_class = state_class self._attr_unique_id = unique_id - self._attr_unit_of_measurement = unit_of_measurement self._attr_device_info = { "identifiers": {(DOMAIN, unique_id)}, diff --git a/homeassistant/components/demo/translations/es-419.json b/homeassistant/components/demo/translations/es-419.json index 8057621520a..d7c6160bc30 100644 --- a/homeassistant/components/demo/translations/es-419.json +++ b/homeassistant/components/demo/translations/es-419.json @@ -4,6 +4,7 @@ "options_1": { "data": { "bool": "Booleano opcional", + "constant": "Constante", "int": "Entrada num\u00e9rica" } }, diff --git a/homeassistant/components/demo/translations/hu.json b/homeassistant/components/demo/translations/hu.json index 0f8f1673d43..3bfe095189a 100644 --- a/homeassistant/components/demo/translations/hu.json +++ b/homeassistant/components/demo/translations/hu.json @@ -1,9 +1,16 @@ { "options": { "step": { + "init": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + } + }, "options_1": { "data": { "bool": "Opcion\u00e1lis logikai \u00e9rt\u00e9k", + "constant": "\u00c1lland\u00f3", "int": "Numerikus bemenet" } }, diff --git a/homeassistant/components/demo/translations/select.es-419.json b/homeassistant/components/demo/translations/select.es-419.json new file mode 100644 index 00000000000..bc66e11847a --- /dev/null +++ b/homeassistant/components/demo/translations/select.es-419.json @@ -0,0 +1,8 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Velocidad de la luz", + "ridiculous_speed": "Velocidad rid\u00edcula" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.he.json b/homeassistant/components/demo/translations/select.he.json new file mode 100644 index 00000000000..0264a0021e2 --- /dev/null +++ b/homeassistant/components/demo/translations/select.he.json @@ -0,0 +1,7 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u05de\u05d4\u05d9\u05e8\u05d5\u05ea \u05d4\u05d0\u05d5\u05e8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/es-419.json b/homeassistant/components/denonavr/translations/es-419.json new file mode 100644 index 00000000000..c506f9f6aac --- /dev/null +++ b/homeassistant/components/denonavr/translations/es-419.json @@ -0,0 +1,15 @@ +{ + "options": { + "step": { + "init": { + "data": { + "update_audyssey": "Actualizar la configuraci\u00f3n de Audyssey", + "zone2": "Configurar Zona 2", + "zone3": "Configurar Zona 3" + }, + "description": "Especificar configuraciones opcionales", + "title": "Receptores de red Denon AVR" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/hu.json b/homeassistant/components/denonavr/translations/hu.json index e6727d3c29f..43ee362d65a 100644 --- a/homeassistant/components/denonavr/translations/hu.json +++ b/homeassistant/components/denonavr/translations/hu.json @@ -3,17 +3,32 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 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" + "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" }, "error": { "discovery_error": "Nem siker\u00fclt megtal\u00e1lni a Denon AVR h\u00e1l\u00f3zati er\u0151s\u00edt\u0151t" }, "flow_title": "{name}", "step": { + "confirm": { + "description": "K\u00e9rj\u00fck, er\u0151s\u00edtse meg a vev\u0151 hozz\u00e1ad\u00e1s\u00e1t", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" + }, + "select": { + "data": { + "select_host": "Vev\u0151 IP-c\u00edme" + }, + "description": "Futtassa \u00fajra a be\u00e1ll\u00edt\u00e1st, ha tov\u00e1bbi vev\u0151k\u00e9sz\u00fcl\u00e9keket szeretne csatlakoztatni", + "title": "V\u00e1lassza ki a csatlakoztatni k\u00edv\u00e1nt vev\u0151t" + }, "user": { "data": { "host": "IP c\u00edm" - } + }, + "description": "Csatlakozzon a vev\u0151h\u00f6z, ha az IP-c\u00edm nincs be\u00e1ll\u00edtva, az automatikus felder\u00edt\u00e9st haszn\u00e1lja", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" } } }, @@ -21,8 +36,13 @@ "step": { "init": { "data": { - "update_audyssey": "Friss\u00edtse az Audyssey be\u00e1ll\u00edt\u00e1sait" - } + "show_all_sources": "Az \u00f6sszes forr\u00e1s megjelen\u00edt\u00e9se", + "update_audyssey": "Friss\u00edtse az Audyssey be\u00e1ll\u00edt\u00e1sait", + "zone2": "\u00c1ll\u00edtsa be a 2. z\u00f3n\u00e1t", + "zone3": "\u00c1ll\u00edtsa be a 3. z\u00f3n\u00e1t" + }, + "description": "Adja meg az opcion\u00e1lis be\u00e1ll\u00edt\u00e1sokat", + "title": "Denon AVR h\u00e1l\u00f3zati vev\u0151k\u00e9sz\u00fcl\u00e9kek" } } } diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 12fc4ddd7ba..45f5db57a90 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -136,8 +136,8 @@ class DerivativeSensor(RestoreEntity, SensorEntity): new_state = event.data.get("new_state") if ( old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): return @@ -196,12 +196,12 @@ class DerivativeSensor(RestoreEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return round(self._state, self._round_digits) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py index 33fd9a8224f..34711a9a2d7 100644 --- a/homeassistant/components/deutsche_bahn/sensor.py +++ b/homeassistant/components/deutsche_bahn/sensor.py @@ -59,7 +59,7 @@ class DeutscheBahnSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the departure time of the next train.""" return self._state diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 93b0b9a4a9d..945774da0b4 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import MutableMapping +from collections.abc import Iterable, Mapping from functools import wraps from types import ModuleType from typing import Any @@ -13,9 +13,12 @@ import voluptuous_serialize from homeassistant.components import websocket_api from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_registry import async_entries_for_device -from homeassistant.loader import IntegrationNotFound +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.loader import IntegrationNotFound, bind_hass from homeassistant.requirements import async_get_integration_with_requirements from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig @@ -49,6 +52,16 @@ TYPES = { } +@bind_hass +async def async_get_device_automations( + hass: HomeAssistant, + automation_type: str, + device_ids: Iterable[str] | None = None, +) -> Mapping[str, Any]: + """Return all the device automations for a type optionally limited to specific device ids.""" + return await _async_get_device_automations(hass, automation_type, device_ids) + + async def async_setup(hass, config): """Set up device automation.""" hass.components.websocket_api.async_register_command( @@ -96,7 +109,7 @@ async def async_get_device_automation_platform( async def _async_get_device_automations_from_domain( - hass, domain, automation_type, device_id + hass, domain, automation_type, device_ids, return_exceptions ): """List device automations.""" try: @@ -104,48 +117,67 @@ async def _async_get_device_automations_from_domain( hass, domain, automation_type ) except InvalidDeviceAutomationConfig: - return None + return {} function_name = TYPES[automation_type][1] - return await getattr(platform, function_name)(hass, device_id) - - -async def _async_get_device_automations(hass, automation_type, device_id): - """List device automations.""" - device_registry, entity_registry = await asyncio.gather( - hass.helpers.device_registry.async_get_registry(), - hass.helpers.entity_registry.async_get_registry(), + return await asyncio.gather( + *( + getattr(platform, function_name)(hass, device_id) + for device_id in device_ids + ), + return_exceptions=return_exceptions, ) - domains = set() - automations: list[MutableMapping[str, Any]] = [] - device = device_registry.async_get(device_id) - if device is None: - raise DeviceNotFound +async def _async_get_device_automations( + hass: HomeAssistant, automation_type: str, device_ids: Iterable[str] | None +) -> Mapping[str, list[dict[str, Any]]]: + """List device automations.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + domain_devices: dict[str, set[str]] = {} + device_entities_domains: dict[str, set[str]] = {} + match_device_ids = set(device_ids or device_registry.devices) + combined_results: dict[str, list[dict[str, Any]]] = {} - for entry_id in device.config_entries: - config_entry = hass.config_entries.async_get_entry(entry_id) - domains.add(config_entry.domain) + for entry in entity_registry.entities.values(): + if not entry.disabled_by and entry.device_id in match_device_ids: + device_entities_domains.setdefault(entry.device_id, set()).add(entry.domain) - entity_entries = async_entries_for_device(entity_registry, device_id) - for entity_entry in entity_entries: - domains.add(entity_entry.domain) + for device_id in match_device_ids: + combined_results[device_id] = [] + device = device_registry.async_get(device_id) + if device is None: + raise DeviceNotFound + for entry_id in device.config_entries: + if config_entry := hass.config_entries.async_get_entry(entry_id): + domain_devices.setdefault(config_entry.domain, set()).add(device_id) + for domain in device_entities_domains.get(device_id, []): + domain_devices.setdefault(domain, set()).add(device_id) - device_automations = await asyncio.gather( + # If specific device ids were requested, we allow + # InvalidDeviceAutomationConfig to be thrown, otherwise we skip + # devices that do not have valid triggers + return_exceptions = not bool(device_ids) + + for domain_results in await asyncio.gather( *( _async_get_device_automations_from_domain( - hass, domain, automation_type, device_id + hass, domain, automation_type, domain_device_ids, return_exceptions ) - for domain in domains + for domain, domain_device_ids in domain_devices.items() ) - ) - for device_automation in device_automations: - if device_automation is not None: - automations.extend(device_automation) + ): + for device_results in domain_results: + if device_results is None or isinstance( + device_results, InvalidDeviceAutomationConfig + ): + continue + for automation in device_results: + combined_results[automation["device_id"]].append(automation) - return automations + return combined_results async def _async_get_device_automation_capabilities(hass, automation_type, automation): @@ -207,7 +239,9 @@ def handle_device_errors(func): async def websocket_device_automation_list_actions(hass, connection, msg): """Handle request for device actions.""" device_id = msg["device_id"] - actions = await _async_get_device_automations(hass, "action", device_id) + actions = (await _async_get_device_automations(hass, "action", [device_id])).get( + device_id + ) connection.send_result(msg["id"], actions) @@ -222,7 +256,9 @@ async def websocket_device_automation_list_actions(hass, connection, msg): async def websocket_device_automation_list_conditions(hass, connection, msg): """Handle request for device conditions.""" device_id = msg["device_id"] - conditions = await _async_get_device_automations(hass, "condition", device_id) + conditions = ( + await _async_get_device_automations(hass, "condition", [device_id]) + ).get(device_id) connection.send_result(msg["id"], conditions) @@ -237,7 +273,9 @@ async def websocket_device_automation_list_conditions(hass, connection, msg): async def websocket_device_automation_list_triggers(hass, connection, msg): """Handle request for device triggers.""" device_id = msg["device_id"] - triggers = await _async_get_device_automations(hass, "trigger", device_id) + triggers = (await _async_get_device_automations(hass, "trigger", [device_id])).get( + device_id + ) connection.send_result(msg["id"], triggers) diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index cf41fc93d83..2e9576ee74a 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -169,10 +169,13 @@ async def async_attach_trigger( async def _async_get_automations( - hass: HomeAssistant, device_id: str, automation_templates: list[dict], domain: str -) -> list[dict]: + hass: HomeAssistant, + device_id: str, + automation_templates: list[dict[str, str]], + domain: str, +) -> list[dict[str, str]]: """List device automations.""" - automations: list[dict[str, Any]] = [] + automations: list[dict[str, str]] = [] entity_registry = await hass.helpers.entity_registry.async_get_registry() entries = [ @@ -197,7 +200,7 @@ async def _async_get_automations( async def async_get_actions( hass: HomeAssistant, device_id: str, domain: str -) -> list[dict]: +) -> list[dict[str, str]]: """List device actions.""" return await _async_get_automations(hass, device_id, ENTITY_ACTIONS, domain) @@ -211,12 +214,14 @@ async def async_get_conditions( async def async_get_triggers( hass: HomeAssistant, device_id: str, domain: str -) -> list[dict]: +) -> list[dict[str, Any]]: """List device triggers.""" return await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, domain) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return { "extra_fields": vol.Schema( @@ -225,7 +230,9 @@ async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> } -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index e73b5a70075..0b8fd6da7f4 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -1,7 +1,7 @@ """Provides device automations for Device Tracker.""" from __future__ import annotations -from typing import Final +from typing import Any, Final import voluptuous as vol @@ -34,7 +34,9 @@ TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Device Tracker devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] diff --git a/homeassistant/components/device_tracker/translations/es-419.json b/homeassistant/components/device_tracker/translations/es-419.json index 8a8b7197dcb..26b8877d4ce 100644 --- a/homeassistant/components/device_tracker/translations/es-419.json +++ b/homeassistant/components/device_tracker/translations/es-419.json @@ -3,6 +3,10 @@ "condition_type": { "is_home": "{entity_name} est\u00e1 en casa", "is_not_home": "{entity_name} no est\u00e1 en casa" + }, + "trigger_type": { + "enters": "{entity_name} ingresa a una zona", + "leaves": "{entity_name} abandona una zona" } }, "state": { diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index ff4d8a01198..f6efc3094d3 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -31,11 +31,11 @@ async def async_setup_entry( for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: for device in gateway.multi_level_switch_devices: for multi_level_switch in device.multi_level_switch_property: - if device.device_model_uid in [ + if device.device_model_uid in ( "devolo.model.Thermostat:Valve", "devolo.model.Room:Thermostat", "devolo.model.Eurotronic:Spirit:Device", - ]: + ): entities.append( DevoloClimateDeviceEntity( homecontrol=gateway, diff --git a/homeassistant/components/devolo_home_control/devolo_device.py b/homeassistant/components/devolo_home_control/devolo_device.py index 781799cbf37..03f850579be 100644 --- a/homeassistant/components/devolo_home_control/devolo_device.py +++ b/homeassistant/components/devolo_home_control/devolo_device.py @@ -45,7 +45,6 @@ class DevoloDeviceEntity(Entity): self.subscriber: Subscriber | None = None self.sync_callback = self._sync self._value: int - self._unit = "" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 7cb8cc8e837..61c3e9a5c19 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -78,7 +78,7 @@ class DevoloMultiLevelDeviceEntity(DevoloDeviceEntity, SensorEntity): """Abstract representation of a multi level sensor within devolo Home Control.""" @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self._value @@ -106,7 +106,7 @@ class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): self._attr_device_class = DEVICE_CLASS_MAPPING.get( self._multi_level_sensor_property.sensor_type ) - self._attr_unit_of_measurement = self._multi_level_sensor_property.unit + self._attr_native_unit_of_measurement = self._multi_level_sensor_property.unit self._value = self._multi_level_sensor_property.value @@ -132,7 +132,7 @@ class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): ) self._attr_device_class = DEVICE_CLASS_MAPPING.get("battery") - self._attr_unit_of_measurement = PERCENTAGE + self._attr_native_unit_of_measurement = PERCENTAGE self._value = device_instance.battery_level @@ -157,15 +157,12 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): self._sensor_type = consumption self._attr_device_class = DEVICE_CLASS_MAPPING.get(consumption) - self._attr_unit_of_measurement = getattr( + self._attr_native_unit_of_measurement = getattr( device_instance.consumption_property[element_uid], f"{consumption}_unit" ) if consumption == "total": - self._attr_state_class = STATE_CLASS_MEASUREMENT - self._attr_last_reset = device_instance.consumption_property[ - element_uid - ].total_since + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self._value = getattr( device_instance.consumption_property[element_uid], consumption @@ -180,15 +177,11 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): def _sync(self, message: tuple) -> None: """Update the consumption sensor state.""" - if message[0] == self._attr_unique_id and message[2] != "total_since": + if message[0] == self._attr_unique_id: self._value = getattr( self._device_instance.consumption_property[self._attr_unique_id], self._sensor_type, ) - elif message[0] == self._attr_unique_id and message[2] == "total_since": - self._attr_last_reset = self._device_instance.consumption_property[ - self._attr_unique_id - ].total_since else: self._generic_message(message) self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/translations/es-419.json b/homeassistant/components/devolo_home_control/translations/es-419.json new file mode 100644 index 00000000000..b9e484949df --- /dev/null +++ b/homeassistant/components/devolo_home_control/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "reauth_failed": "Utilice el mismo usuario mydevolo que antes." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index 730a1824e1a..316f36e3630 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -42,12 +42,12 @@ class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity): return GLUCOSE_VALUE_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the device.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: return getattr(self.coordinator.data, self._attribute_unit_of_measurement) @@ -82,7 +82,7 @@ class DexcomGlucoseTrendSensor(CoordinatorEntity, SensorEntity): return GLUCOSE_TREND_ICON[0] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: return self.coordinator.data.trend_description diff --git a/homeassistant/components/dexcom/translations/es-419.json b/homeassistant/components/dexcom/translations/es-419.json new file mode 100644 index 00000000000..a2d55e2b462 --- /dev/null +++ b/homeassistant/components/dexcom/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "server": "Servidor" + }, + "description": "Ingrese las credenciales de Dexcom Share", + "title": "Configurar la integraci\u00f3n de Dexcom" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "unit_of_measurement": "Unidad de medida" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/hu.json b/homeassistant/components/dexcom/translations/hu.json index 45f38b22a84..039eb56f8f0 100644 --- a/homeassistant/components/dexcom/translations/hu.json +++ b/homeassistant/components/dexcom/translations/hu.json @@ -14,7 +14,9 @@ "password": "Jelsz\u00f3", "server": "Szerver", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "Adja meg a Dexcom Share hiteles\u00edt\u0151 adatait", + "title": "Dexcom integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sa" } } }, diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 7003038593b..1a49667bad8 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -41,6 +41,7 @@ from homeassistant.helpers.event import ( async_track_state_added_domain, async_track_time_interval, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_dhcp from homeassistant.util.network import is_invalid, is_link_local, is_loopback @@ -58,7 +59,7 @@ SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" async def _initialize(_): diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index 1300c165b37..810db33e5e4 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -1,5 +1,6 @@ """Support for Adafruit DHT temperature and humidity sensor.""" -from contextlib import suppress +from __future__ import annotations + from datetime import timedelta import logging @@ -7,7 +8,11 @@ import adafruit_dht import board 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, @@ -15,11 +20,10 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -34,10 +38,22 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], - SENSOR_HUMIDITY: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], -} +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", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] def validate_pin_input(value): @@ -54,7 +70,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_SENSOR): cv.string, vol.Required(CONF_PIN): vol.All(cv.string, validate_pin_input), vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TEMPERATURE_OFFSET, default=0): vol.All( @@ -69,7 +85,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DHT sensor.""" - SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit available_sensors = { "AM2302": adafruit_dht.DHT22, "DHT11": adafruit_dht.DHT11, @@ -86,22 +101,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return False data = DHTClient(sensor, pin, name) - dev = [] - with suppress(KeyError): - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - DHTSensor( - data, - variable, - SENSOR_TYPES[variable][1], - name, - temperature_offset, - humidity_offset, - ) - ) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + DHTSensor(data, name, temperature_offset, humidity_offset, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(dev, True) + add_entities(entities, True) class DHTSensor(SensorEntity): @@ -110,38 +118,18 @@ class DHTSensor(SensorEntity): def __init__( self, dht_client, - sensor_type, - temp_unit, name, temperature_offset, humidity_offset, + description: SensorEntityDescription, ): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.dht_client = dht_client - self.temp_unit = temp_unit - self.type = sensor_type self.temperature_offset = temperature_offset self.humidity_offset = humidity_offset - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{name} {description.name}" def update(self): """Get the latest data from the DHT and updates the states.""" @@ -150,7 +138,8 @@ class DHTSensor(SensorEntity): humidity_offset = self.humidity_offset data = self.dht_client.data - if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMPERATURE and sensor_type in data: temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug( "Temperature %.1f \u00b0C + offset %.1f", @@ -158,14 +147,12 @@ class DHTSensor(SensorEntity): temperature_offset, ) if -20 <= temperature < 80: - self._state = round(temperature + temperature_offset, 1) - if self.temp_unit == TEMP_FAHRENHEIT: - self._state = round(celsius_to_fahrenheit(temperature), 1) - elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: + self._attr_native_value = round(temperature + temperature_offset, 1) + elif sensor_type == SENSOR_HUMIDITY and sensor_type in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) if 0 <= humidity <= 100: - self._state = round(humidity + humidity_offset, 1) + self._attr_native_value = round(humidity + humidity_offset, 1) class DHTClient: diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py index 9ad01a0179b..853386fd1d8 100644 --- a/homeassistant/components/directv/const.py +++ b/homeassistant/components/directv/const.py @@ -3,14 +3,10 @@ DOMAIN = "directv" # Attributes -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" 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_MODEL = "model" -ATTR_SOFTWARE_VERSION = "sw_version" ATTR_VIA_DEVICE = "via_device" CONF_RECEIVER_ID = "receiver_id" diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py index c632ad7e84c..2e6ffb81a52 100644 --- a/homeassistant/components/directv/entity.py +++ b/homeassistant/components/directv/entity.py @@ -3,17 +3,16 @@ from __future__ import annotations from directv import DIRECTV -from homeassistant.const import ATTR_NAME -from homeassistant.helpers.entity import DeviceInfo, Entity - -from .const import ( +from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, - ATTR_SOFTWARE_VERSION, - ATTR_VIA_DEVICE, - DOMAIN, + ATTR_NAME, + ATTR_SW_VERSION, ) +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import ATTR_VIA_DEVICE, DOMAIN class DIRECTVEntity(Entity): @@ -34,6 +33,6 @@ class DIRECTVEntity(Entity): ATTR_NAME: self.name, ATTR_MANUFACTURER: self.dtv.device.info.brand, ATTR_MODEL: None, - ATTR_SOFTWARE_VERSION: self.dtv.device.info.version, + ATTR_SW_VERSION: self.dtv.device.info.version, ATTR_VIA_DEVICE: (DOMAIN, self.dtv.device.info.receiver_id), } diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 6d69ba2fd5a..3fba13121f1 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -3,7 +3,7 @@ "name": "DirecTV", "documentation": "https://www.home-assistant.io/integrations/directv", "requirements": ["directv==0.4.0"], - "codeowners": ["@ctalkington"], + "codeowners": [], "quality_scale": "gold", "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/directv/translations/he.json b/homeassistant/components/directv/translations/he.json index bc28ff4eba5..f057c4e4629 100644 --- a/homeassistant/components/directv/translations/he.json +++ b/homeassistant/components/directv/translations/he.json @@ -9,14 +9,6 @@ }, "flow_title": "{name}", "step": { - "ssdp_confirm": { - "data": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - } - }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/directv/translations/hu.json b/homeassistant/components/directv/translations/hu.json index 0309eb35881..3e0a7d5cb57 100644 --- a/homeassistant/components/directv/translations/hu.json +++ b/homeassistant/components/directv/translations/hu.json @@ -7,7 +7,15 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, + "flow_title": "{name}", "step": { + "ssdp_confirm": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + }, + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + }, "user": { "data": { "host": "Hoszt" diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 81beec0e60e..3d90956a2b5 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -105,7 +105,7 @@ class DiscogsSensor(SensorEntity): return f"{self._name} {SENSORS[self._type]['name']}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -115,7 +115,7 @@ class DiscogsSensor(SensorEntity): return SENSORS[self._type]["icon"] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return SENSORS[self._type]["unit_of_measurement"] diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 5b6bb7a5372..8bf31a94aef 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -55,7 +55,6 @@ SERVICE_HANDLERS = { "bose_soundtouch": ("media_player", "soundtouch"), "bluesound": ("media_player", "bluesound"), "lg_smart_device": ("media_player", "lg_soundbar"), - "nanoleaf_aurora": ("light", "nanoleaf"), } OPTIONAL_SERVICE_HANDLERS = {SERVICE_DLNA_DMR: ("media_player", "dlna_dmr")} @@ -87,6 +86,7 @@ MIGRATED_SERVICE_HANDLERS = [ SERVICE_XIAOMI_GW, "volumio", SERVICE_YEELIGHT, + "nanoleaf_aurora", ] DEFAULT_ENABLED = ( diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index e9ac437fe46..67d9713628a 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.19.1"], + "requirements": ["async-upnp-client==0.20.0"], "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 2fb0e30da90..a429d336379 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -79,6 +79,6 @@ class WanIpSensor(SensorEntity): response = None if response: - self._attr_state = response[0].host + self._attr_native_value = response[0].host else: - self._attr_state = None + self._attr_native_value = None diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index d5964d5aea0..07366ad1a9a 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import get_url +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, slugify from .const import ( @@ -58,7 +59,7 @@ DEVICE_SCHEMA = vol.Schema( CONFIG_SCHEMA = cv.deprecated(DOMAIN) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the DoorBird component.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 53fcdbcee70..16606156314 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -1,4 +1,6 @@ """Support for viewing the camera feed from a DoorBird video doorbell.""" +from __future__ import annotations + import asyncio import datetime import logging @@ -112,7 +114,9 @@ class DoorBirdCamera(DoorBirdEntity, Camera): """Get the name of the camera.""" return self._name - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Pull a still image from the camera.""" now = dt_util.utcnow() diff --git a/homeassistant/components/doorbird/translations/hu.json b/homeassistant/components/doorbird/translations/hu.json index 3f74783b7ac..cb4c46e699a 100644 --- a/homeassistant/components/doorbird/translations/hu.json +++ b/homeassistant/components/doorbird/translations/hu.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "link_local_address": "A linkek helyi c\u00edmei nem t\u00e1mogatottak", + "not_doorbird_device": "Ez az eszk\u00f6z nem DoorBird" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -16,7 +18,18 @@ "name": "Eszk\u00f6z neve", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a DoorBird-hez" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Vessz\u0151vel elv\u00e1lasztott esem\u00e9nyek list\u00e1ja." + }, + "description": "Adjon hozz\u00e1 vessz\u0151vel elv\u00e1lasztott esem\u00e9nynevet minden k\u00f6vetni k\u00edv\u00e1nt esem\u00e9nyhez. Miut\u00e1n itt megadta \u0151ket, haszn\u00e1lja a DoorBird alkalmaz\u00e1st, hogy hozz\u00e1rendelje \u0151ket egy adott esem\u00e9nyhez. Tekintse meg a dokument\u00e1ci\u00f3t a https://www.home-assistant.io/integrations/doorbird/#events c\u00edmen. P\u00e9lda: valaki_pr\u00e9selt_gomb, mozg\u00e1s" } } } diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index e7b3dbdd363..180a886740f 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -1,10 +1,17 @@ """Support for sensors from the Dovado router.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import re 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_SENSORS, DATA_GIGABYTES, PERCENTAGE import homeassistant.helpers.config_validation as cv @@ -18,26 +25,59 @@ SENSOR_SIGNAL = "signal" SENSOR_NETWORK = "network" SENSOR_SMS_UNREAD = "sms" -SENSORS = { - SENSOR_NETWORK: ("signal strength", "Network", None, "mdi:access-point-network"), - SENSOR_SIGNAL: ( - "signal strength", - "Signal Strength", - PERCENTAGE, - "mdi:signal", + +@dataclass +class DovadoRequiredKeysMixin: + """Mixin for required keys.""" + + identifier: str + + +@dataclass +class DovadoSensorEntityDescription(SensorEntityDescription, DovadoRequiredKeysMixin): + """Describes Dovado sensor entity.""" + + +SENSOR_TYPES: tuple[DovadoSensorEntityDescription, ...] = ( + DovadoSensorEntityDescription( + identifier=SENSOR_NETWORK, + key="signal strength", + name="Network", + icon="mdi:access-point-network", ), - SENSOR_SMS_UNREAD: ("sms unread", "SMS unread", "", "mdi:message-text-outline"), - SENSOR_UPLOAD: ("traffic modem tx", "Sent", DATA_GIGABYTES, "mdi:cloud-upload"), - SENSOR_DOWNLOAD: ( - "traffic modem rx", - "Received", - DATA_GIGABYTES, - "mdi:cloud-download", + DovadoSensorEntityDescription( + identifier=SENSOR_SIGNAL, + key="signal strength", + name="Signal Strength", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:signal", ), -} + DovadoSensorEntityDescription( + identifier=SENSOR_SMS_UNREAD, + key="sms unread", + name="SMS unread", + icon="mdi:message-text-outline", + ), + DovadoSensorEntityDescription( + identifier=SENSOR_UPLOAD, + key="traffic modem tx", + name="Sent", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:cloud-upload", + ), + DovadoSensorEntityDescription( + identifier=SENSOR_DOWNLOAD, + key="traffic modem rx", + name="Received", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:cloud-download", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)])} + {vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSOR_KEYS)])} ) @@ -45,63 +85,50 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Dovado sensor platform.""" dovado = hass.data[DOVADO_DOMAIN] - entities = [] - for sensor in config[CONF_SENSORS]: - entities.append(DovadoSensor(dovado, sensor)) - + sensors = config[CONF_SENSORS] + entities = [ + DovadoSensor(dovado, description) + for description in SENSOR_TYPES + if description.key in sensors + ] add_entities(entities) class DovadoSensor(SensorEntity): """Representation of a Dovado sensor.""" - def __init__(self, data, sensor): + entity_description: DovadoSensorEntityDescription + + def __init__(self, data, description: DovadoSensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self._data = data - self._sensor = sensor - self._state = self._compute_state() + + self._attr_name = f"{data.name} {description.name}" + self._attr_native_value = self._compute_state() def _compute_state(self): """Compute the state of the sensor.""" - state = self._data.state.get(SENSORS[self._sensor][0]) - if self._sensor == SENSOR_NETWORK: + state = self._data.state.get(self.entity_description.key) + sensor_identifier = self.entity_description.identifier + if sensor_identifier == SENSOR_NETWORK: match = re.search(r"\((.+)\)", state) return match.group(1) if match else None - if self._sensor == SENSOR_SIGNAL: + if sensor_identifier == SENSOR_SIGNAL: try: return int(state.split()[0]) except ValueError: return None - if self._sensor == SENSOR_SMS_UNREAD: + if sensor_identifier == SENSOR_SMS_UNREAD: return int(state) - if self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: + if sensor_identifier in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: return round(float(state) / 1e6, 1) return state def update(self): """Update sensor values.""" self._data.update() - self._state = self._compute_state() - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._data.name} {SENSORS[self._sensor][1]}" - - @property - def state(self): - """Return the sensor state.""" - return self._state - - @property - def icon(self): - """Return the icon for the sensor.""" - return SENSORS[self._sensor][3] - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSORS[self._sensor][2] + self._attr_native_value = self._compute_state() @property def extra_state_attributes(self): diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 72e854fe43a..9670aab21cf 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -28,6 +28,7 @@ from .const import ( CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE, DOMAIN, + DSMR_VERSIONS, LOGGER, ) @@ -70,6 +71,10 @@ class DSMRConnection: if self._equipment_identifier in telegram: self._telegram = telegram transport.close() + # Swedish meters have no equipment identifier + if self._dsmr_version == "5S" and obis_ref.P1_MESSAGE_TIMESTAMP in telegram: + self._telegram = telegram + transport.close() if self._host is None: reader_factory = partial( @@ -119,7 +124,7 @@ async def _validate_dsmr_connection( equipment_identifier_gas = conn.equipment_identifier_gas() # Check only for equipment identifier in case no gas meter is connected - if equipment_identifier is None: + if equipment_identifier is None and data[CONF_DSMR_VERSION] != "5S": raise CannotCommunicate return { @@ -203,7 +208,7 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required(CONF_HOST): str, vol.Required(CONF_PORT): int, - vol.Required(CONF_DSMR_VERSION): vol.In(["2.2", "4", "5", "5B", "5L"]), + vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), } ) return self.async_show_form( @@ -247,7 +252,7 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): schema = vol.Schema( { vol.Required(CONF_PORT): vol.In(list_of_ports), - vol.Required(CONF_DSMR_VERSION): vol.In(["2.2", "4", "5", "5B", "5L"]), + vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), } ) return self.async_show_form( @@ -288,8 +293,9 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data = {**data, **info} - await self.async_set_unique_id(info[CONF_SERIAL_ID]) - self._abort_if_unique_id_configured() + if info[CONF_SERIAL_ID]: + await self.async_set_unique_id(info[CONF_SERIAL_ID]) + self._abort_if_unique_id_configured() except CannotConnect: errors["base"] = "cannot_connect" except CannotCommunicate: @@ -316,8 +322,9 @@ class DSMRFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): name = f"{host}:{port}" if host is not None else port data = {**import_config, **info} - await self.async_set_unique_id(info[CONF_SERIAL_ID]) - self._abort_if_unique_id_configured(data) + if info[CONF_SERIAL_ID]: + await self.async_set_unique_id(info[CONF_SERIAL_ID]) + self._abort_if_unique_id_configured(data) return self.async_create_entry(title=name, data=data) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index a5e51816183..ba90fa9b697 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -5,14 +5,17 @@ import logging from dsmr_parser import obis_references -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, ) -from homeassistant.util import dt from .models import DSMRSensorEntityDescription @@ -41,6 +44,8 @@ DATA_TASK = "task" DEVICE_NAME_ENERGY = "Energy Meter" DEVICE_NAME_GAS = "Gas Meter" +DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S"} + SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.CURRENT_ELECTRICITY_USAGE, @@ -59,39 +64,40 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_ACTIVE_TARIFF, name="Power Tariff", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, icon="mdi:flash", ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_USED_TARIFF_1, name="Energy Consumption (tarif 1)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=DEVICE_CLASS_ENERGY, force_update=True, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_USED_TARIFF_2, name="Energy Consumption (tarif 2)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, name="Energy Production (tarif 1)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, name="Energy Production (tarif 2)", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, @@ -138,45 +144,53 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key=obis_references.SHORT_POWER_FAILURE_COUNT, name="Short Power Failure Count", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", ), DSMRSensorEntityDescription( key=obis_references.LONG_POWER_FAILURE_COUNT, name="Long Power Failure Count", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L1_COUNT, name="Voltage Sags Phase L1", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L2_COUNT, name="Voltage Sags Phase L2", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SAG_L3_COUNT, name="Voltage Sags Phase L3", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L1_COUNT, name="Voltage Swells Phase L1", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L2_COUNT, name="Voltage Swells Phase L2", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), DSMRSensorEntityDescription( key=obis_references.VOLTAGE_SWELL_L3_COUNT, name="Voltage Swells Phase L3", + dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", ), @@ -228,8 +242,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, @@ -237,8 +250,23 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, + name="Energy Consumption (total)", + dsmr_versions={"5S"}, + force_update=True, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key=obis_references.SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + name="Energy Production (total)", + dsmr_versions={"5S"}, + force_update=True, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_IMPORTED_TOTAL, @@ -246,8 +274,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"2.2", "4", "5", "5B"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.HOURLY_GAS_METER_READING, @@ -255,9 +282,8 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"4", "5", "5L"}, is_gas=True, force_update=True, - icon="mdi:fire", - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.BELGIUM_HOURLY_GAS_METER_READING, @@ -265,9 +291,8 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"5B"}, is_gas=True, force_update=True, - icon="mdi:fire", - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRSensorEntityDescription( key=obis_references.GAS_METER_READING, @@ -275,8 +300,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( dsmr_versions={"2.2"}, is_gas=True, force_update=True, - icon="mdi:fire", - last_reset=dt.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, ), ) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index df738724ac0..fbbfac55959 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dsmr", "name": "DSMR Slimme Meter", "documentation": "https://www.home-assistant.io/integrations/dsmr", - "requirements": ["dsmr_parser==0.29"], + "requirements": ["dsmr_parser==0.30"], "codeowners": ["@Robbie1221", "@frenck"], "config_flow": true, "iot_class": "local_push" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index faff62ddeb4..bd02be7d63e 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -16,11 +16,16 @@ 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_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + VOLUME_CUBIC_METERS, +) from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.helpers.typing import ConfigType, EventType, StateType from homeassistant.util import Throttle from .const import ( @@ -39,6 +44,7 @@ from .const import ( DEVICE_NAME_ENERGY, DEVICE_NAME_GAS, DOMAIN, + DSMR_VERSIONS, LOGGER, SENSORS, ) @@ -49,13 +55,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( - cv.string, vol.In(["5L", "5B", "5", "4", "2.2"]) + cv.string, vol.In(DSMR_VERSIONS) ), vol.Optional(CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL): int, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), } ) +UNIT_CONVERSION = {"m3": VOLUME_CUBIC_METERS} + async def async_setup_platform( hass: HomeAssistant, @@ -111,7 +119,7 @@ async def async_setup_entry( create_tcp_dsmr_reader, entry.data[CONF_HOST], entry.data[CONF_PORT], - entry.data[CONF_DSMR_VERSION], + dsmr_version, update_entities_telegram, loop=hass.loop, keep_alive_interval=60, @@ -120,7 +128,7 @@ async def async_setup_entry( reader_factory = partial( create_dsmr_reader, entry.data[CONF_PORT], - entry.data[CONF_DSMR_VERSION], + dsmr_version, update_entities_telegram, loop=hass.loop, ) @@ -131,22 +139,29 @@ async def async_setup_entry( transport = None protocol = None - while hass.state != CoreState.stopping: + while hass.state == CoreState.not_running or hass.is_running: # Start DSMR asyncio.Protocol reader try: transport, protocol = await hass.loop.create_task(reader_factory()) if transport: # Register listener to close transport on HA shutdown + @callback + def close_transport(_event: EventType) -> None: + """Close the transport on HA shutdown.""" + if not transport: + return + transport.close() + stop_listener = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, transport.close + EVENT_HOMEASSISTANT_STOP, close_transport ) # Wait for reader to close await protocol.wait_closed() # Unexpected disconnect - if not hass.is_stopping: + if hass.state == CoreState.not_running or hass.is_running: stop_listener() transport = None @@ -173,7 +188,9 @@ async def async_setup_entry( entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) ) except CancelledError: - if stop_listener: + if stop_listener and ( + hass.state == CoreState.not_running or hass.is_running + ): stop_listener() # pylint: disable=not-callable if transport: @@ -210,6 +227,8 @@ class DSMREntity(SensorEntity): if entity_description.is_gas: device_serial = entry.data[CONF_SERIAL_ID_GAS] device_name = DEVICE_NAME_GAS + if device_serial is None: + device_serial = entry.entry_id self._attr_device_info = { "identifiers": {(DOMAIN, device_serial)}, @@ -238,7 +257,7 @@ class DSMREntity(SensorEntity): return attr @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of sensor, if available, translate if needed.""" value = self.get_dsmr_object_attr("value") if value is None: @@ -258,9 +277,12 @@ class DSMREntity(SensorEntity): return None @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" - return self.get_dsmr_object_attr("unit") + unit_of_measurement = self.get_dsmr_object_attr("unit") + if unit_of_measurement in UNIT_CONVERSION: + return UNIT_CONVERSION[unit_of_measurement] + return unit_of_measurement @staticmethod def translate_tariff(value: str, dsmr_version: str) -> str | None: diff --git a/homeassistant/components/dsmr/translations/es-419.json b/homeassistant/components/dsmr/translations/es-419.json new file mode 100644 index 00000000000..82e9427b171 --- /dev/null +++ b/homeassistant/components/dsmr/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "setup_serial": { + "title": "Dispositivo" + }, + "setup_serial_manual_path": { + "title": "Ruta" + }, + "user": { + "data": { + "type": "Tipo de conecci\u00f3n" + }, + "title": "Seleccione el tipo de conexi\u00f3n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "time_between_update": "Tiempo m\u00ednimo entre actualizaciones de entidad [s]" + }, + "title": "Opciones DSMR" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 1a46f86132b..533b2f0dd38 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -6,12 +6,14 @@ from typing import Callable from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntityDescription, ) from homeassistant.const import ( CURRENCY_EURO, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_POWER, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, @@ -21,7 +23,6 @@ from homeassistant.const import ( POWER_KILO_WATT, VOLUME_CUBIC_METERS, ) -from homeassistant.util import dt as dt_util def dsmr_transform(value): @@ -50,46 +51,42 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/reading/electricity_delivered_1", name="Low tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_returned_1", name="Low tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_delivered_2", name="High tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_returned_2", name="High tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_currently_delivered", name="Current power usage", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/electricity_currently_returned", name="Current power return", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -97,7 +94,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power usage L1", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -105,7 +102,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power usage L2", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -113,7 +110,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power usage L3", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -121,7 +118,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power return L1", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -129,7 +126,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power return L2", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -137,7 +134,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current power return L3", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -145,16 +142,15 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Gas meter usage", entity_registry_enabled_default=False, icon="mdi:fire", - unit_of_measurement=VOLUME_CUBIC_METERS, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/reading/phase_voltage_l1", name="Current voltage L1", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -162,7 +158,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current voltage L2", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -170,7 +166,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Current voltage L3", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -178,7 +174,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Phase power current L1", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -186,7 +182,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Phase power current L2", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -194,7 +190,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( name="Phase power current L3", entity_registry_enabled_default=False, device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -206,16 +202,15 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/delivered", name="Gas usage", - icon="mdi:fire", - unit_of_measurement=VOLUME_CUBIC_METERS, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + device_class=DEVICE_CLASS_GAS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", name="Current gas usage", - icon="mdi:fire", - unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, state_class=STATE_CLASS_MEASUREMENT, ), DSMRReaderSensorEntityDescription( @@ -228,121 +223,115 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/electricity1", name="Low tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2", name="High tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_returned", name="Low tariff return", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_returned", name="High tariff return", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_merged", name="Power usage total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_returned_merged", name="Power return total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt_util.utc_from_timestamp(0), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_cost", name="Low tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_cost", name="High tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_cost_merged", name="Power total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas", name="Gas usage", icon="mdi:counter", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas_cost", name="Gas cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/total_cost", name="Total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", name="Low tariff delivered price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", name="High tariff delivered price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", name="Low tariff returned price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", name="High tariff returned price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_gas", name="Gas price", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/fixed_cost", name="Current day fixed cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/dsmr_version", @@ -415,156 +404,156 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-month/electricity1", name="Current month low tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2", name="Current month high tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_returned", name="Current month low tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_returned", name="Current month high tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_merged", name="Current month power usage total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_returned_merged", name="Current month power return total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_cost", name="Current month low tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_cost", name="Current month high tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_cost_merged", name="Current month power total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas", name="Current month gas usage", icon="mdi:counter", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas_cost", name="Current month gas cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/fixed_cost", name="Current month fixed cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/total_cost", name="Current month total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1", name="Current year low tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2", name="Current year high tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_returned", name="Current year low tariff returned", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_returned", name="Current year high tariff usage", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_merged", name="Current year power usage total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_returned_merged", name="Current year power returned total", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_cost", name="Current year low tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_cost", name="Current year high tariff cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_cost_merged", name="Current year power total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas", name="Current year gas usage", icon="mdi:counter", - unit_of_measurement=VOLUME_CUBIC_METERS, + native_unit_of_measurement=VOLUME_CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas_cost", name="Current year gas cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/fixed_cost", name="Current year fixed cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/total_cost", name="Current year total cost", icon="mdi:currency-eur", - unit_of_measurement=CURRENCY_EURO, + native_unit_of_measurement=CURRENCY_EURO, ), ) diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 39356db46b5..84947ec41f1 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -33,9 +33,9 @@ class DSMRSensor(SensorEntity): def message_received(message): """Handle new MQTT messages.""" if self.entity_description.state is not None: - self._attr_state = self.entity_description.state(message.payload) + self._attr_native_value = self.entity_description.state(message.payload) else: - self._attr_state = message.payload + self._attr_native_value = message.payload self.async_write_ha_state() diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py index 4e095955818..5b08e8e142c 100644 --- a/homeassistant/components/dte_energy_bridge/sensor.py +++ b/homeassistant/components/dte_energy_bridge/sensor.py @@ -66,12 +66,12 @@ class DteEnergyBridgeSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index dbe1d10b553..b7daf661e63 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -82,7 +82,7 @@ class DublinPublicTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -105,7 +105,7 @@ class DublinPublicTransportSensor(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/dunehd/config_flow.py b/homeassistant/components/dunehd/config_flow.py index b6aec1e62f5..6c6f12280f5 100644 --- a/homeassistant/components/dunehd/config_flow.py +++ b/homeassistant/components/dunehd/config_flow.py @@ -21,7 +21,7 @@ _LOGGER: Final = logging.getLogger(__name__) def host_valid(host: str) -> bool: """Return True if hostname or IP address is valid.""" try: - if ipaddress.ip_address(host).version in [4, 6]: + if ipaddress.ip_address(host).version in (4, 6): return True except ValueError: pass diff --git a/homeassistant/components/dunehd/translations/es-419.json b/homeassistant/components/dunehd/translations/es-419.json new file mode 100644 index 00000000000..5ad7a6640b4 --- /dev/null +++ b/homeassistant/components/dunehd/translations/es-419.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Configure la integraci\u00f3n de Dune HD. Si tiene problemas con la configuraci\u00f3n, vaya a: https://www.home-assistant.io/integrations/dunehd \n\n Aseg\u00farese de que su reproductor est\u00e9 encendido.", + "title": "Dune HD" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dunehd/translations/hu.json b/homeassistant/components/dunehd/translations/hu.json index cf0b593d546..148a6fde0d0 100644 --- a/homeassistant/components/dunehd/translations/hu.json +++ b/homeassistant/components/dunehd/translations/hu.json @@ -13,6 +13,7 @@ "data": { "host": "Hoszt" }, + "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/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 428ed3ab427..2668e573b7c 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -112,7 +112,7 @@ class DwdWeatherWarningsSensor(SensorEntity): self._attr_name = f"{name} {description.name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.entity_description.key == CURRENT_WARNING_SENSOR: return self._api.api.current_warning_level diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py index f1243cd5407..3d980b34d00 100644 --- a/homeassistant/components/dweet/sensor.py +++ b/homeassistant/components/dweet/sensor.py @@ -73,12 +73,12 @@ class DweetSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state.""" return self._state diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 7dc3d86afe6..49e742519fd 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, C from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow from .bridge import DynaliteBridge @@ -179,7 +180,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" conf = config.get(DOMAIN) LOGGER.debug("Setting up dynalite component config = %s", conf) diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index cff4b8f5501..be83a7e4373 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -129,7 +129,7 @@ class DysonSensor(DysonEntity, SensorEntity): return f"{self._device.serial}-{self._sensor_type}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -152,7 +152,7 @@ class DysonFilterLifeSensor(DysonSensor): super().__init__(device, "filter_life") @property - def state(self): + def native_value(self): """Return filter life in hours.""" return int(self._device.state.filter_life) @@ -165,7 +165,7 @@ class DysonCarbonFilterLifeSensor(DysonSensor): super().__init__(device, "carbon_filter_state") @property - def state(self): + def native_value(self): """Return filter life remaining in percent.""" return int(self._device.state.carbon_filter_state) @@ -178,7 +178,7 @@ class DysonHepaFilterLifeSensor(DysonSensor): super().__init__(device, f"{filter_type}_filter_state") @property - def state(self): + def native_value(self): """Return filter life remaining in percent.""" return int(self._device.state.hepa_filter_state) @@ -191,7 +191,7 @@ class DysonDustSensor(DysonSensor): super().__init__(device, "dust") @property - def state(self): + def native_value(self): """Return Dust value.""" return self._device.environmental_state.dust @@ -204,7 +204,7 @@ class DysonHumiditySensor(DysonSensor): super().__init__(device, "humidity") @property - def state(self): + def native_value(self): """Return Humidity value.""" if self._device.environmental_state.humidity == 0: return STATE_OFF @@ -220,7 +220,7 @@ class DysonTemperatureSensor(DysonSensor): self._unit = unit @property - def state(self): + def native_value(self): """Return Temperature value.""" temperature_kelvin = self._device.environmental_state.temperature if temperature_kelvin == 0: @@ -230,7 +230,7 @@ class DysonTemperatureSensor(DysonSensor): return float(f"{(temperature_kelvin * 9 / 5 - 459.67):.1f}") @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @@ -243,6 +243,6 @@ class DysonAirQualitySensor(DysonSensor): super().__init__(device, "air_quality") @property - def state(self): + def native_value(self): """Return Air Quality value.""" return int(self._device.environmental_state.volatil_organic_compounds) diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index b3d726f9cd3..bc2158e4db8 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -149,7 +149,7 @@ class Measurement(CoordinatorEntity, SensorEntity): return True @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return units for the sensor.""" measure = self.coordinator.data["measures"][self.key] if "unit" not in measure: @@ -162,6 +162,6 @@ class Measurement(CoordinatorEntity, SensorEntity): return {ATTR_ATTRIBUTION: self.attribution} @property - def state(self): + def native_value(self): """Return the current sensor value.""" return self.coordinator.data["measures"][self.key]["latestReading"]["value"] diff --git a/homeassistant/components/eafm/translations/es-419.json b/homeassistant/components/eafm/translations/es-419.json new file mode 100644 index 00000000000..52757f0a1d5 --- /dev/null +++ b/homeassistant/components/eafm/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "no_stations": "No se encontraron estaciones de monitoreo de inundaciones." + }, + "step": { + "user": { + "data": { + "station": "Estaci\u00f3n" + }, + "description": "Seleccione la estaci\u00f3n que desea monitorear", + "title": "Seguimiento de una estaci\u00f3n de monitoreo de inundaciones" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eafm/translations/hu.json b/homeassistant/components/eafm/translations/hu.json index 38863029f12..820958e4e6e 100644 --- a/homeassistant/components/eafm/translations/hu.json +++ b/homeassistant/components/eafm/translations/hu.json @@ -1,13 +1,16 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "no_stations": "Nem tal\u00e1lhat\u00f3 \u00e1rv\u00edzfigyel\u0151 \u00e1llom\u00e1s." }, "step": { "user": { "data": { "station": "\u00c1llom\u00e1s" - } + }, + "description": "V\u00e1lassza ki a figyelni k\u00edv\u00e1nt \u00e1llom\u00e1st", + "title": "\u00c1rv\u00edzfigyel\u0151 \u00e1llom\u00e1s nyomon k\u00f6vet\u00e9se" } } } diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index e98dea45929..3c43dd36130 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -45,79 +45,79 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="usage", name="Usage", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", ), SensorEntityDescription( key="balance", name="Balance", - unit_of_measurement=PRICE, + native_unit_of_measurement=PRICE, icon="mdi:cash-usd", ), SensorEntityDescription( key="limit", name="Data limit", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="days_left", name="Days left", - unit_of_measurement=TIME_DAYS, + native_unit_of_measurement=TIME_DAYS, icon="mdi:calendar-today", ), SensorEntityDescription( key="before_offpeak_download", name="Download before offpeak", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="before_offpeak_upload", name="Upload before offpeak", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:upload", ), SensorEntityDescription( key="before_offpeak_total", name="Total before offpeak", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="offpeak_download", name="Offpeak download", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="offpeak_upload", name="Offpeak Upload", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:upload", ), SensorEntityDescription( key="offpeak_total", name="Offpeak Total", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="download", name="Download", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), SensorEntityDescription( key="upload", name="Upload", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:upload", ), SensorEntityDescription( key="total", name="Total", - unit_of_measurement=DATA_GIGABITS, + native_unit_of_measurement=DATA_GIGABITS, icon="mdi:download", ), ) @@ -179,7 +179,7 @@ class EBoxSensor(SensorEntity): """Get the latest data from EBox and update the state.""" await self.ebox_data.async_update() if self.entity_description.key in self.ebox_data.data: - self._attr_state = round( + self._attr_native_value = round( self.ebox_data.data[self.entity_description.key], 2 ) diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index abd9620130d..dcfd4ec7eef 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -56,7 +56,7 @@ class EbusdSensor(SensorEntity): return f"{self._client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -94,7 +94,7 @@ class EbusdSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/ecoal_boiler/sensor.py b/homeassistant/components/ecoal_boiler/sensor.py index 9a2fbdd9b87..d9689631280 100644 --- a/homeassistant/components/ecoal_boiler/sensor.py +++ b/homeassistant/components/ecoal_boiler/sensor.py @@ -33,7 +33,7 @@ class EcoalTempSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -43,7 +43,7 @@ class EcoalTempSensor(SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return TEMP_CELSIUS diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 24ba36bedd8..dfa6cf4cb0a 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -1,7 +1,9 @@ """Support for Ecobee sensors.""" +from __future__ import annotations + from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -11,45 +13,51 @@ from homeassistant.const import ( from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER -SENSOR_TYPES = { - "temperature": ["Temperature", TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE], - "humidity": ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_FAHRENHEIT, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up ecobee (temperature and humidity) sensors.""" data = hass.data[DOMAIN] - dev = [] - for index in range(len(data.ecobee.thermostats)): - for sensor in data.ecobee.get_remote_sensors(index): - for item in sensor["capability"]: - if item["type"] not in ("temperature", "humidity"): - continue + entities = [ + EcobeeSensor(data, sensor["name"], index, description) + for index in range(len(data.ecobee.thermostats)) + for sensor in data.ecobee.get_remote_sensors(index) + for item in sensor["capability"] + for description in SENSOR_TYPES + if description.key == item["type"] + ] - dev.append(EcobeeSensor(data, sensor["name"], item["type"], index)) - - async_add_entities(dev, True) + async_add_entities(entities, True) class EcobeeSensor(SensorEntity): """Representation of an Ecobee sensor.""" - def __init__(self, data, sensor_name, sensor_type, sensor_index): + def __init__( + self, data, sensor_name, sensor_index, description: SensorEntityDescription + ): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = f"{sensor_name} {SENSOR_TYPES[sensor_type][0]}" self.sensor_name = sensor_name - self.type = sensor_type self.index = sensor_index self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - @property - def name(self): - """Return the name of the Ecobee sensor.""" - return self._name + self._attr_name = f"{sensor_name} {description.name}" @property def unique_id(self): @@ -100,32 +108,20 @@ class EcobeeSensor(SensorEntity): return thermostat["runtime"]["connected"] @property - def device_class(self): - """Return the device class of the sensor.""" - if self.type in (DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE): - return self.type - return None - - @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - if self._state in [ + if self._state in ( ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN, "unknown", - ]: + ): return None - if self.type == "temperature": + if self.entity_description.key == "temperature": return float(self._state) / 10 return self._state - @property - def unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - async def async_update(self): """Get the latest state of the sensor.""" await self.data.update() @@ -133,7 +129,7 @@ class EcobeeSensor(SensorEntity): if sensor["name"] != self.sensor_name: continue for item in sensor["capability"]: - if item["type"] != self.type: + if item["type"] != self.entity_description.key: continue self._state = item["value"] break diff --git a/homeassistant/components/ecobee/translations/en_GB.json b/homeassistant/components/ecobee/translations/en_GB.json new file mode 100644 index 00000000000..21fc733743c --- /dev/null +++ b/homeassistant/components/ecobee/translations/en_GB.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "authorize": { + "description": "Please authorise this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, press Submit.", + "title": "Authorise app on ecobee.com" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 0dfe8df7fb3..bbcf54003e8 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -82,7 +82,7 @@ class EcoNetSensor(EcoNetEntity, SensorEntity): self._device_name = device_name @property - def state(self): + def native_value(self): """Return sensors state.""" value = getattr(self._econet, SENSOR_NAMES_TO_ATTRIBUTES[self._device_name]) if isinstance(value, float): @@ -90,7 +90,7 @@ class EcoNetSensor(EcoNetEntity, SensorEntity): return value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" unit_of_measurement = SENSOR_NAMES_TO_UNIT_OF_MEASUREMENT[self._device_name] if self._device_name == POWER_USAGE_TODAY: diff --git a/homeassistant/components/econet/translations/es-419.json b/homeassistant/components/econet/translations/es-419.json new file mode 100644 index 00000000000..f019a47ae4a --- /dev/null +++ b/homeassistant/components/econet/translations/es-419.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Configurar cuenta Rheem EcoNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 9adb7665753..1eee0b47272 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -114,7 +114,7 @@ class EddystoneTemp(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.temperature @@ -124,7 +124,7 @@ class EddystoneTemp(SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return TEMP_CELSIUS diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index d2d0d375733..407f5902198 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -59,6 +59,7 @@ class EDL21: "1-0:0.0.9*255": "Electricity ID", # D=2: Program entries "1-0:0.2.0*0": "Configuration program version number", + "1-0:0.2.0*1": "Firmware version number", # C=1: Active power + # D=8: Time integral 1 # E=0: Total @@ -94,6 +95,10 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:31.7.0*255": "L1 active instantaneous amperage", + # C=32: Active voltage L1 + # D=7: Instantaneous value + # E=0: Total + "1-0:32.7.0*255": "L1 active instantaneous voltage", # C=36: Active power L1 # D=7: Instantaneous value # E=0: Total @@ -102,6 +107,10 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:51.7.0*255": "L2 active instantaneous amperage", + # C=52: Active voltage L2 + # D=7: Instantaneous value + # E=0: Total + "1-0:52.7.0*255": "L2 active instantaneous voltage", # C=56: Active power L2 # D=7: Instantaneous value # E=0: Total @@ -110,13 +119,21 @@ class EDL21: # D=7: Instantaneous value # E=0: Total "1-0:71.7.0*255": "L3 active instantaneous amperage", + # C=72: Active voltage L3 + # D=7: Instantaneous value + # E=0: Total + "1-0:72.7.0*255": "L3 active instantaneous voltage", # C=76: Active power L3 # D=7: Instantaneous value # E=0: Total "1-0:76.7.0*255": "L3 active instantaneous power", # C=81: Angles # D=7: Instantaneous value + # E=4: U(L1) x I(L1) + # E=15: U(L2) x I(L2) # E=26: U(L3) x I(L3) + "1-0:81.7.4*255": "U(L1)/I(L1) phase angle", + "1-0:81.7.15*255": "U(L2)/I(L2) phase angle", "1-0:81.7.26*255": "U(L3)/I(L3) phase angle", # C=96: Electricity-related service entries "1-0:96.1.0*255": "Metering point ID 1", @@ -126,6 +143,7 @@ class EDL21: # C=96: Electricity-related service entries "1-0:96.50.1*1", # Manufacturer specific "1-0:96.90.2*1", # Manufacturer specific + "1-0:96.90.2*2", # Manufacturer specific # A=129: Manufacturer specific "129-129:199.130.3*255", # Iskraemeco: Manufacturer "129-129:199.130.5*255", # Iskraemeco: Public Key @@ -283,7 +301,7 @@ class EDL21Entity(SensorEntity): return self._name @property - def state(self) -> str: + def native_value(self) -> str: """Return the value of the last received telegram.""" return self._telegram.get("value") @@ -297,7 +315,7 @@ class EDL21Entity(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._telegram.get("unit") diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 6e2ac1c01c7..391aca7b4af 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -120,12 +120,12 @@ class EfergySensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index 01413ceaec0..df0d7882491 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -101,12 +101,12 @@ class EightHeatSensor(EightSleepHeatEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return PERCENTAGE @@ -157,12 +157,12 @@ class EightUserSensor(EightSleepUserEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if ( "current_sleep" in self._sensor @@ -316,7 +316,7 @@ class EightRoomSensor(EightSleepUserEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -333,7 +333,7 @@ class EightRoomSensor(EightSleepUserEntity, SensorEntity): self._state = None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if self._units == "si": return TEMP_CELSIUS diff --git a/homeassistant/components/elgato/translations/ca.json b/homeassistant/components/elgato/translations/ca.json index 354e67e00b0..79acea81004 100644 --- a/homeassistant/components/elgato/translations/ca.json +++ b/homeassistant/components/elgato/translations/ca.json @@ -14,7 +14,7 @@ "host": "Amfitri\u00f3", "port": "Port" }, - "description": "Configura l'Elgato Light per integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 d'Elgato Light amb Home Assistant." }, "zeroconf_confirm": { "description": "Vols afegir a Home Assistant l'Elgato Light amb n\u00famero de s\u00e8rie `{serial_number}`?", diff --git a/homeassistant/components/elgato/translations/hu.json b/homeassistant/components/elgato/translations/hu.json index ef6404bd92d..0cd9f2589b8 100644 --- a/homeassistant/components/elgato/translations/hu.json +++ b/homeassistant/components/elgato/translations/hu.json @@ -13,7 +13,12 @@ "data": { "host": "Hoszt", "port": "Port" - } + }, + "description": "\u00c1ll\u00edtsa be az Elgato Light-ot, hogy integr\u00e1lhat\u00f3 legyen az HomeAssistantba." + }, + "zeroconf_confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni a \"{serial_number}\" sorozatsz\u00e1m\u00fa Elgato Light-ot az HomeAssistanthoz?", + "title": "Felfedezett Elgato Light eszk\u00f6z(\u00f6k)" } } } diff --git a/homeassistant/components/elgato/translations/zh-Hans.json b/homeassistant/components/elgato/translations/zh-Hans.json index 254f6df9327..94813c444eb 100644 --- a/homeassistant/components/elgato/translations/zh-Hans.json +++ b/homeassistant/components/elgato/translations/zh-Hans.json @@ -1,10 +1,25 @@ { "config": { "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "flow_title": "{serial_number}", + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3" + }, + "description": "\u8bbe\u7f6e\u60a8\u7684 Elgato Light \u4ee5\u4e0e Home Assistant \u96c6\u6210\u3002" + }, + "zeroconf_confirm": { + "description": "\u60a8\u60f3\u5c06\u5e8f\u5217\u53f7\u4e3a `{serial_number}` \u7684 Elgato Light \u6dfb\u52a0\u5230 Home Assistant \u5417\uff1f", + "title": "\u53d1\u73b0 Elgato Light \u88c5\u7f6e" + } } } } \ No newline at end of file diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index 253913b3779..ecd6e4ad4bb 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -78,12 +78,12 @@ class EliqSensor(SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return UNIT_OF_MEASUREMENT @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 8f26af545b7..30fe87103c7 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -77,7 +77,7 @@ class ElkSensor(ElkAttachedEntity, SensorEntity): self._state = None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -127,7 +127,7 @@ class ElkKeypad(ElkSensor): return self._temperature_unit @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._temperature_unit @@ -250,7 +250,7 @@ class ElkZone(ElkSensor): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self._element.definition == ZoneType.TEMPERATURE.value: return self._temperature_unit diff --git a/homeassistant/components/elkm1/translations/hu.json b/homeassistant/components/elkm1/translations/hu.json index 83862dfb75f..ff6445f0b72 100644 --- a/homeassistant/components/elkm1/translations/hu.json +++ b/homeassistant/components/elkm1/translations/hu.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "address_already_configured": "Az ElkM1 ezzel a c\u00edmmel m\u00e1r konfigur\u00e1lva van", + "already_configured": "Az ezzel az el\u0151taggal rendelkez\u0151 ElkM1 m\u00e1r konfigur\u00e1lva van" + }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", @@ -8,10 +12,15 @@ "step": { "user": { "data": { + "address": "Az IP-c\u00edm vagy tartom\u00e1ny vagy soros port, ha soros kapcsolaton kereszt\u00fcl csatlakozik.", "password": "Jelsz\u00f3", + "prefix": "Egyedi el\u0151tag (hagyja \u00fcresen, ha csak egy ElkM1 van).", "protocol": "Protokoll", + "temperature_unit": "Az ElkM1 h\u0151m\u00e9rs\u00e9kleti egys\u00e9g haszn\u00e1lja.", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A c\u00edmsornak a \u201ebiztons\u00e1gos\u201d \u00e9s a \u201enem biztons\u00e1gos\u201d \u201ec\u00edm [: port]\u201d form\u00e1tum\u00fanak kell lennie. P\u00e9lda: '192.168.1.1'. A port opcion\u00e1lis, \u00e9s alap\u00e9rtelmez\u00e9s szerint 2101 \u201enem biztons\u00e1gos\u201d \u00e9s 2601 \u201ebiztons\u00e1gos\u201d. A soros protokollhoz a c\u00edmnek 'tty [: baud]' form\u00e1tum\u00fanak kell lennie. P\u00e9lda: '/dev/ttyS1'. A baud opcion\u00e1lis, \u00e9s alap\u00e9rtelmez\u00e9s szerint 115200.", + "title": "Csatlakoz\u00e1s az Elk-M1 vez\u00e9rl\u0151h\u00f6z" } } } diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index bfc86db387e..033f7878b5e 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -5,7 +5,12 @@ import logging import requests 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, +) from homeassistant.const import ( CONF_API_KEY, CONF_ID, @@ -13,6 +18,8 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_VALUE_TEMPLATE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, HTTP_OK, POWER_WATT, STATE_UNKNOWN, @@ -149,6 +156,13 @@ class EmonCmsSensor(SensorEntity): self._sensorid = sensorid self._elem = elem + if unit_of_measurement == "kWh": + self._attr_device_class = DEVICE_CLASS_ENERGY + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + elif unit_of_measurement == "W": + self._attr_device_class = DEVICE_CLASS_POWER + self._attr_state_class = STATE_CLASS_MEASUREMENT + if self._value_template is not None: self._state = self._value_template.render_with_possible_json_value( elem["value"], STATE_UNKNOWN @@ -162,12 +176,12 @@ class EmonCmsSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/emonitor/__init__.py b/homeassistant/components/emonitor/__init__.py index 69c8b907b72..91263db5127 100644 --- a/homeassistant/components/emonitor/__init__.py +++ b/homeassistant/components/emonitor/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = aiohttp_client.async_get_clientsession(hass) emonitor = Emonitor(entry.data[CONF_HOST], session) - coordinator = DataUpdateCoordinator( + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( hass, _LOGGER, name=entry.title, diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 1dca3f2d89d..1d699b42473 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -38,7 +38,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): """Representation of an Emonitor power sensor entity.""" _attr_device_class = DEVICE_CLASS_POWER - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT def __init__(self, coordinator: DataUpdateCoordinator, channel_number: int) -> None: """Initialize the channel sensor.""" @@ -73,7 +73,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): return attr_val @property - def state(self) -> StateType: + def native_value(self) -> StateType: """State of the sensor.""" return self._paired_attr("inst_power") diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py index b9dc79e25cc..d513669cd00 100644 --- a/homeassistant/components/emulated_kasa/__init__.py +++ b/homeassistant/components/emulated_kasa/__init__.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.template import Template, is_template_string +from homeassistant.helpers.typing import ConfigType from .const import CONF_POWER, CONF_POWER_ENTITY, DOMAIN @@ -48,7 +49,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the emulated_kasa component.""" conf = config.get(DOMAIN) if not conf: diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 419a34db98c..bb3ac2082f8 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -2,7 +2,7 @@ "domain": "emulated_kasa", "name": "Emulated Kasa", "documentation": "https://www.home-assistant.io/integrations/emulated_kasa", - "requirements": ["sense_energy==0.9.0"], + "requirements": ["sense_energy==0.9.2"], "codeowners": ["@kbickar"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/emulated_roku/translations/lt.json b/homeassistant/components/emulated_roku/translations/lt.json new file mode 100644 index 00000000000..8ae517ecfbe --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host_ip": "Hosto IP adresas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index c053dea4741..1cea20564b4 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -79,7 +79,34 @@ class SolarSourceType(TypedDict): config_entry_solar_forecast: list[str] | None -SourceType = Union[GridSourceType, SolarSourceType] +class BatterySourceType(TypedDict): + """Dictionary holding the source of battery storage.""" + + type: Literal["battery"] + + stat_energy_from: str + stat_energy_to: str + + +class GasSourceType(TypedDict): + """Dictionary holding the source of gas storage.""" + + type: Literal["gas"] + + stat_energy_from: str + + # statistic_id of costs ($) incurred from the energy meter + # If set to None and entity_energy_from and entity_energy_price are configured, + # an EnergyCostSensor will be automatically created + stat_cost: str | None + + # Used to generate costs if stat_cost is set to None + entity_energy_from: str | None # entity_id of an gas meter (m³), entity_id of the gas meter for stat_energy_from + entity_energy_price: str | None # entity_id of an entity providing price ($/m³) + number_energy_price: float | None # Price for energy ($/m³) + + +SourceType = Union[GridSourceType, SolarSourceType, BatterySourceType, GasSourceType] class DeviceConsumption(TypedDict): @@ -177,6 +204,23 @@ SOLAR_SOURCE_SCHEMA = vol.Schema( vol.Optional("config_entry_solar_forecast"): vol.Any([str], None), } ) +BATTERY_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "battery", + vol.Required("stat_energy_from"): str, + vol.Required("stat_energy_to"): str, + } +) +GAS_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "gas", + vol.Required("stat_energy_from"): str, + vol.Optional("stat_cost"): vol.Any(str, None), + vol.Optional("entity_energy_from"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } +) def check_type_limits(value: list[SourceType]) -> list[SourceType]: @@ -197,6 +241,8 @@ ENERGY_SOURCE_SCHEMA = vol.All( { "grid": GRID_SOURCE_SCHEMA, "solar": SOLAR_SOURCE_SCHEMA, + "battery": BATTERY_SOURCE_SCHEMA, + "gas": GAS_SOURCE_SCHEMA, }, ) ] diff --git a/homeassistant/components/energy/manifest.json b/homeassistant/components/energy/manifest.json index 3a3cbeff4e7..5ddc6457a61 100644 --- a/homeassistant/components/energy/manifest.json +++ b/homeassistant/components/energy/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/energy", "codeowners": ["@home-assistant/core"], "iot_class": "calculated", - "dependencies": ["websocket_api", "history"], + "dependencies": ["websocket_api", "history", "recorder"], "quality_scale": "internal" } diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index e974035cbd6..45ef8ea5c17 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -2,26 +2,26 @@ from __future__ import annotations from dataclasses import dataclass -from functools import partial import logging from typing import Any, Final, Literal, TypeVar, cast from homeassistant.components.sensor import ( - ATTR_LAST_RESET, + ATTR_STATE_CLASS, DEVICE_CLASS_MONETARY, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) +from homeassistant.components.sensor.recorder import reset_detected from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, + VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .const import DOMAIN from .data import EnergyManager, async_get_manager @@ -36,22 +36,19 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the energy sensors.""" - manager = await async_get_manager(hass) - process_now = partial(_process_manager_data, hass, manager, async_add_entities, {}) - manager.async_listen_updates(process_now) - - if manager.data: - await process_now() + sensor_manager = SensorManager(await async_get_manager(hass), async_add_entities) + await sensor_manager.async_start() T = TypeVar("T") @dataclass -class FlowAdapter: - """Adapter to allow flows to be used as sensors.""" +class SourceAdapter: + """Adapter to allow sources and their flows to be used as sensors.""" - flow_type: Literal["flow_from", "flow_to"] + source_type: Literal["grid", "gas"] + flow_type: Literal["flow_from", "flow_to", None] stat_energy_key: Literal["stat_energy_from", "stat_energy_to"] entity_energy_key: Literal["entity_energy_from", "entity_energy_to"] total_money_key: Literal["stat_cost", "stat_compensation"] @@ -59,8 +56,9 @@ class FlowAdapter: entity_id_suffix: str -FLOW_ADAPTERS: Final = ( - FlowAdapter( +SOURCE_ADAPTERS: Final = ( + SourceAdapter( + "grid", "flow_from", "stat_energy_from", "entity_energy_from", @@ -68,7 +66,8 @@ FLOW_ADAPTERS: Final = ( "Cost", "cost", ), - FlowAdapter( + SourceAdapter( + "grid", "flow_to", "stat_energy_to", "entity_energy_to", @@ -76,67 +75,112 @@ FLOW_ADAPTERS: Final = ( "Compensation", "compensation", ), + SourceAdapter( + "gas", + None, + "stat_energy_from", + "entity_energy_from", + "stat_cost", + "Cost", + "cost", + ), ) -async def _process_manager_data( - hass: HomeAssistant, - manager: EnergyManager, - async_add_entities: AddEntitiesCallback, - current_entities: dict[tuple[str, str], EnergyCostSensor], -) -> None: - """Process updated data.""" - to_add: list[SensorEntity] = [] - to_remove = dict(current_entities) +class SensorManager: + """Class to handle creation/removal of sensor data.""" - async def finish() -> None: - if to_add: - async_add_entities(to_add) + def __init__( + self, manager: EnergyManager, async_add_entities: AddEntitiesCallback + ) -> None: + """Initialize sensor manager.""" + self.manager = manager + self.async_add_entities = async_add_entities + self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {} - for key, entity in to_remove.items(): - current_entities.pop(key) - await entity.async_remove() + async def async_start(self) -> None: + """Start.""" + self.manager.async_listen_updates(self._process_manager_data) + + if self.manager.data: + await self._process_manager_data() + + async def _process_manager_data(self) -> None: + """Process manager data.""" + to_add: list[SensorEntity] = [] + to_remove = dict(self.current_entities) + + async def finish() -> None: + if to_add: + self.async_add_entities(to_add) + + for key, entity in to_remove.items(): + self.current_entities.pop(key) + await entity.async_remove() + + if not self.manager.data: + await finish() + return + + for energy_source in self.manager.data["energy_sources"]: + for adapter in SOURCE_ADAPTERS: + if adapter.source_type != energy_source["type"]: + continue + + if adapter.flow_type is None: + self._process_sensor_data( + adapter, + # Opting out of the type complexity because can't get it to work + energy_source, # type: ignore + to_add, + to_remove, + ) + continue + + for flow in energy_source[adapter.flow_type]: # type: ignore + self._process_sensor_data( + adapter, + # Opting out of the type complexity because can't get it to work + flow, # type: ignore + to_add, + to_remove, + ) - if not manager.data: await finish() - return - for energy_source in manager.data["energy_sources"]: - if energy_source["type"] != "grid": - continue + @callback + def _process_sensor_data( + self, + adapter: SourceAdapter, + config: dict, + to_add: list[SensorEntity], + to_remove: dict[tuple[str, str | None, str], EnergyCostSensor], + ) -> None: + """Process sensor data.""" + # No need to create an entity if we already have a cost stat + if config.get(adapter.total_money_key) is not None: + return - for adapter in FLOW_ADAPTERS: - for flow in energy_source[adapter.flow_type]: - # Opting out of the type complexity because can't get it to work - untyped_flow = cast(dict, flow) + key = (adapter.source_type, adapter.flow_type, config[adapter.stat_energy_key]) - # No need to create an entity if we already have a cost stat - if untyped_flow.get(adapter.total_money_key) is not None: - continue + # Make sure the right data is there + # If the entity existed, we don't pop it from to_remove so it's removed + if config.get(adapter.entity_energy_key) is None or ( + config.get("entity_energy_price") is None + and config.get("number_energy_price") is None + ): + return - # This is unique among all flow_from's - key = (adapter.flow_type, untyped_flow[adapter.stat_energy_key]) + current_entity = to_remove.pop(key, None) + if current_entity: + current_entity.update_config(config) + return - # Make sure the right data is there - # If the entity existed, we don't pop it from to_remove so it's removed - if untyped_flow.get(adapter.entity_energy_key) is None or ( - untyped_flow.get("entity_energy_price") is None - and untyped_flow.get("number_energy_price") is None - ): - continue - - current_entity = to_remove.pop(key, None) - if current_entity: - current_entity.update_config(untyped_flow) - continue - - current_entities[key] = EnergyCostSensor( - adapter, - untyped_flow, - ) - to_add.append(current_entities[key]) - - await finish() + self.current_entities[key] = EnergyCostSensor( + adapter, + config, + ) + to_add.append(self.current_entities[key]) class EnergyCostSensor(SensorEntity): @@ -146,27 +190,31 @@ class EnergyCostSensor(SensorEntity): utility. """ + _wrong_state_class_reported = False + _wrong_unit_reported = False + def __init__( self, - adapter: FlowAdapter, - flow: dict, + adapter: SourceAdapter, + config: dict, ) -> None: """Initialize the sensor.""" super().__init__() self._adapter = adapter - self.entity_id = f"{flow[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" + self.entity_id = ( + 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._flow = flow - self._last_energy_sensor_state: State | None = None + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._config = config + self._last_energy_sensor_state: StateType | None = None self._cur_value = 0.0 - def _reset(self, energy_state: State) -> None: + def _reset(self, energy_state: StateType) -> None: """Reset the cost sensor.""" - self._attr_state = 0.0 + 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() @@ -174,10 +222,22 @@ class EnergyCostSensor(SensorEntity): def _update_cost(self) -> None: """Update incurred costs.""" energy_state = self.hass.states.get( - cast(str, self._flow[self._adapter.entity_energy_key]) + cast(str, self._config[self._adapter.entity_energy_key]) ) - if energy_state is None or ATTR_LAST_RESET not in energy_state.attributes: + if energy_state is None: + return + + if ( + state_class := energy_state.attributes.get(ATTR_STATE_CLASS) + ) != STATE_CLASS_TOTAL_INCREASING: + if not self._wrong_state_class_reported: + self._wrong_state_class_reported = True + _LOGGER.warning( + "Found unexpected state_class %s for %s", + state_class, + energy_state.entity_id, + ) return try: @@ -186,8 +246,10 @@ class EnergyCostSensor(SensorEntity): return # Determine energy price - if self._flow["entity_energy_price"] is not None: - energy_price_state = self.hass.states.get(self._flow["entity_energy_price"]) + if self._config["entity_energy_price"] is not None: + energy_price_state = self.hass.states.get( + self._config["entity_energy_price"] + ) if energy_price_state is None: return @@ -197,51 +259,69 @@ class EnergyCostSensor(SensorEntity): except ValueError: return - if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith( - f"/{ENERGY_WATT_HOUR}" + if ( + self._adapter.source_type == "grid" + and energy_price_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, "" + ).endswith(f"/{ENERGY_WATT_HOUR}") ): energy_price *= 1000.0 else: energy_price_state = None - energy_price = cast(float, self._flow["number_energy_price"]) + energy_price = cast(float, self._config["number_energy_price"]) if self._last_energy_sensor_state is None: # Initialize as it's the first time all required entities are in place. - self._reset(energy_state) + self._reset(energy_state.state) return energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if energy_unit == ENERGY_WATT_HOUR: - energy_price /= 1000 - elif energy_unit != ENERGY_KILO_WATT_HOUR: - _LOGGER.warning( - "Found unexpected unit %s for %s", energy_unit, energy_state.entity_id - ) + if self._adapter.source_type == "grid": + if energy_unit == ENERGY_WATT_HOUR: + energy_price /= 1000 + elif energy_unit != ENERGY_KILO_WATT_HOUR: + energy_unit = None + + elif self._adapter.source_type == "gas": + if energy_unit != VOLUME_CUBIC_METERS: + energy_unit = None + + if energy_unit is None: + if not self._wrong_unit_reported: + self._wrong_unit_reported = True + _LOGGER.warning( + "Found unexpected unit %s for %s", + energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + energy_state.entity_id, + ) return - if ( - energy_state.attributes[ATTR_LAST_RESET] - != self._last_energy_sensor_state.attributes[ATTR_LAST_RESET] + if reset_detected( + self.hass, + cast(str, self._config[self._adapter.entity_energy_key]), + energy, + float(self._last_energy_sensor_state), ): # Energy meter was reset, reset cost sensor too - self._reset(energy_state) - else: - # Update with newly incurred cost - old_energy_value = float(self._last_energy_sensor_state.state) - self._cur_value += (energy - old_energy_value) * energy_price - self._attr_state = round(self._cur_value, 2) + self._reset(0) + # Update with newly incurred cost + old_energy_value = float(self._last_energy_sensor_state) + self._cur_value += (energy - old_energy_value) * energy_price + self._attr_native_value = round(self._cur_value, 2) - self._last_energy_sensor_state = energy_state + self._last_energy_sensor_state = energy_state.state async def async_added_to_hass(self) -> None: """Register callbacks.""" - energy_state = self.hass.states.get(self._flow[self._adapter.entity_energy_key]) + energy_state = self.hass.states.get( + self._config[self._adapter.entity_energy_key] + ) if energy_state: name = energy_state.name else: - name = split_entity_id(self._flow[self._adapter.entity_energy_key])[ + name = split_entity_id(self._config[self._adapter.entity_energy_key])[ 0 ].replace("_", " ") @@ -251,7 +331,7 @@ class EnergyCostSensor(SensorEntity): # Store stat ID in hass.data so frontend can look it up self.hass.data[DOMAIN]["cost_sensors"][ - self._flow[self._adapter.entity_energy_key] + self._config[self._adapter.entity_energy_key] ] = self.entity_id @callback @@ -263,7 +343,7 @@ class EnergyCostSensor(SensorEntity): self.async_on_remove( async_track_state_change_event( self.hass, - cast(str, self._flow[self._adapter.entity_energy_key]), + cast(str, self._config[self._adapter.entity_energy_key]), async_state_changed_listener, ) ) @@ -271,16 +351,16 @@ class EnergyCostSensor(SensorEntity): async def async_will_remove_from_hass(self) -> None: """Handle removing from hass.""" self.hass.data[DOMAIN]["cost_sensors"].pop( - self._flow[self._adapter.entity_energy_key] + self._config[self._adapter.entity_energy_key] ) await super().async_will_remove_from_hass() @callback - def update_config(self, flow: dict) -> None: + def update_config(self, config: dict) -> None: """Update the config.""" - self._flow = flow + self._config = config @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the units of measurement.""" return self.hass.config.currency diff --git a/homeassistant/components/energy/translations/es-419.json b/homeassistant/components/energy/translations/es-419.json new file mode 100644 index 00000000000..64c2f5bffa1 --- /dev/null +++ b/homeassistant/components/energy/translations/es-419.json @@ -0,0 +1,3 @@ +{ + "title": "Energ\u00eda" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/es.json b/homeassistant/components/energy/translations/es.json new file mode 100644 index 00000000000..64c2f5bffa1 --- /dev/null +++ b/homeassistant/components/energy/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Energ\u00eda" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/fi.json b/homeassistant/components/energy/translations/fi.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/fi.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/hu.json b/homeassistant/components/energy/translations/hu.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/hu.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/no.json b/homeassistant/components/energy/translations/no.json new file mode 100644 index 00000000000..168ae4ae877 --- /dev/null +++ b/homeassistant/components/energy/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "Energi" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/zh-Hans.json b/homeassistant/components/energy/translations/zh-Hans.json new file mode 100644 index 00000000000..bae50fae66e --- /dev/null +++ b/homeassistant/components/energy/translations/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "title": "\u80fd\u6e90" +} \ No newline at end of file diff --git a/homeassistant/components/energy/types.py b/homeassistant/components/energy/types.py new file mode 100644 index 00000000000..b8df1b19bef --- /dev/null +++ b/homeassistant/components/energy/types.py @@ -0,0 +1,27 @@ +"""Types for the energy platform.""" +from __future__ import annotations + +from typing import Awaitable, Callable, TypedDict + +from homeassistant.core import HomeAssistant + + +class SolarForecastType(TypedDict): + """Return value for solar forecast.""" + + wh_hours: dict[str, float | int] + + +GetSolarForecastType = Callable[ + [HomeAssistant, str], Awaitable["SolarForecastType | None"] +] + + +class EnergyPlatform: + """This class represents the methods we expect on the energy platforms.""" + + @staticmethod + async def async_get_solar_forecast( + hass: HomeAssistant, config_entry_id: str + ) -> SolarForecastType | None: + """Get forecast for solar production for specific config entry ID.""" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py new file mode 100644 index 00000000000..01709081d68 --- /dev/null +++ b/homeassistant/components/energy/validate.py @@ -0,0 +1,277 @@ +"""Validate the energy preferences provide valid data.""" +from __future__ import annotations + +import dataclasses +from typing import Any + +from homeassistant.components import recorder, sensor +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, callback, valid_entity_id + +from . import data +from .const import DOMAIN + + +@dataclasses.dataclass +class ValidationIssue: + """Error or warning message.""" + + type: str + identifier: str + value: Any | None = None + + +@dataclasses.dataclass +class EnergyPreferencesValidation: + """Dictionary holding validation information.""" + + energy_sources: list[list[ValidationIssue]] = dataclasses.field( + default_factory=list + ) + device_consumption: list[list[ValidationIssue]] = dataclasses.field( + default_factory=list + ) + + def as_dict(self) -> dict: + """Return dictionary version.""" + return dataclasses.asdict(self) + + +@callback +def _async_validate_energy_stat( + hass: HomeAssistant, stat_value: str, result: list[ValidationIssue] +) -> None: + """Validate a statistic.""" + has_entity_source = valid_entity_id(stat_value) + + if not has_entity_source: + return + + if not recorder.is_entity_recorded(hass, stat_value): + result.append( + ValidationIssue( + "recorder_untracked", + stat_value, + ) + ) + return + + state = hass.states.get(stat_value) + + if state is None: + result.append( + ValidationIssue( + "entity_not_defined", + stat_value, + ) + ) + return + + if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + result.append(ValidationIssue("entity_unavailable", stat_value, state.state)) + return + + try: + current_value: float | None = float(state.state) + except ValueError: + result.append( + ValidationIssue("entity_state_non_numeric", stat_value, state.state) + ) + return + + if current_value is not None and current_value < 0: + result.append( + ValidationIssue("entity_negative_state", stat_value, current_value) + ) + + unit = state.attributes.get("unit_of_measurement") + + if unit not in (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR): + result.append( + ValidationIssue("entity_unexpected_unit_energy", stat_value, unit) + ) + + state_class = state.attributes.get("state_class") + + if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + result.append( + ValidationIssue( + "entity_unexpected_state_class_total_increasing", + stat_value, + state_class, + ) + ) + + +@callback +def _async_validate_price_entity( + hass: HomeAssistant, entity_id: str, result: list[ValidationIssue] +) -> None: + """Validate that the price entity is correct.""" + state = hass.states.get(entity_id) + + if state is None: + result.append( + ValidationIssue( + "entity_not_defined", + entity_id, + ) + ) + return + + try: + value: float | None = float(state.state) + except ValueError: + result.append( + ValidationIssue("entity_state_non_numeric", entity_id, state.state) + ) + return + + if value is not None and value < 0: + result.append(ValidationIssue("entity_negative_state", entity_id, value)) + + 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)) + + +@callback +def _async_validate_cost_stat( + hass: HomeAssistant, stat_id: str, result: list[ValidationIssue] +) -> None: + """Validate that the cost stat is correct.""" + has_entity = valid_entity_id(stat_id) + + if not has_entity: + return + + if not recorder.is_entity_recorded(hass, stat_id): + result.append( + ValidationIssue( + "recorder_untracked", + stat_id, + ) + ) + + +@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) + + if state is None: + result.append( + ValidationIssue( + "entity_not_defined", + entity_id, + ) + ) + return + + state_class = state.attributes.get("state_class") + + if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + result.append( + ValidationIssue( + "entity_unexpected_state_class_total_increasing", entity_id, state_class + ) + ) + + +async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: + """Validate the energy configuration.""" + manager = await data.async_get_manager(hass) + + result = EnergyPreferencesValidation() + + if manager.data is None: + return result + + for source in manager.data["energy_sources"]: + source_result: list[ValidationIssue] = [] + result.energy_sources.append(source_result) + + if source["type"] == "grid": + for flow in source["flow_from"]: + _async_validate_energy_stat( + hass, flow["stat_energy_from"], source_result + ) + + 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 + ) + _async_validate_cost_entity( + hass, + hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_from"]], + source_result, + ) + + for flow in source["flow_to"]: + _async_validate_energy_stat(hass, flow["stat_energy_to"], source_result) + + if flow.get("stat_compensation") is not None: + _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 + ) + _async_validate_cost_entity( + hass, + hass.data[DOMAIN]["cost_sensors"][flow["stat_energy_to"]], + source_result, + ) + + elif source["type"] == "gas": + _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + + 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 + ) + _async_validate_cost_entity( + hass, + hass.data[DOMAIN]["cost_sensors"][source["stat_energy_from"]], + source_result, + ) + + elif source["type"] == "solar": + _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + + elif source["type"] == "battery": + _async_validate_energy_stat(hass, source["stat_energy_from"], source_result) + _async_validate_energy_stat(hass, source["stat_energy_to"], source_result) + + for device in manager.data["device_consumption"]: + device_result: list[ValidationIssue] = [] + result.device_consumption.append(device_result) + _async_validate_energy_stat(hass, device["stat_consumption"], device_result) + + return result diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index d1c8869a1c2..7af7b306f79 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -3,12 +3,17 @@ from __future__ import annotations import asyncio import functools -from typing import Any, Awaitable, Callable, Dict, cast +from types import ModuleType +from typing import Any, Awaitable, Callable, cast import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.integration_platform import ( + async_process_integration_platforms, +) +from homeassistant.helpers.singleton import singleton from .const import DOMAIN from .data import ( @@ -18,13 +23,15 @@ from .data import ( EnergyPreferencesUpdate, async_get_manager, ) +from .types import EnergyPlatform, GetSolarForecastType +from .validate import async_validate EnergyWebSocketCommandHandler = Callable[ - [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], + [HomeAssistant, websocket_api.ActiveConnection, "dict[str, Any]", "EnergyManager"], None, ] AsyncEnergyWebSocketCommandHandler = Callable[ - [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], + [HomeAssistant, websocket_api.ActiveConnection, "dict[str, Any]", "EnergyManager"], Awaitable[None], ] @@ -35,6 +42,29 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_prefs) websocket_api.async_register_command(hass, ws_save_prefs) websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_validate) + websocket_api.async_register_command(hass, ws_solar_forecast) + + +@singleton("energy_platforms") +async def async_get_energy_platforms( + hass: HomeAssistant, +) -> dict[str, GetSolarForecastType]: + """Get energy platforms.""" + platforms: dict[str, GetSolarForecastType] = {} + + async def _process_energy_platform( + hass: HomeAssistant, domain: str, platform: ModuleType + ) -> None: + """Process energy platforms.""" + if not hasattr(platform, "async_get_solar_forecast"): + return + + platforms[domain] = cast(EnergyPlatform, platform).async_get_solar_forecast + + await async_process_integration_platforms(hass, DOMAIN, _process_energy_platform) + + return platforms def _ws_with_manager( @@ -105,11 +135,86 @@ async def ws_save_prefs( vol.Required("type"): "energy/info", } ) -@callback -def ws_info( +@websocket_api.async_response +async def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle get info command.""" - connection.send_result(msg["id"], hass.data[DOMAIN]) + forecast_platforms = await async_get_energy_platforms(hass) + connection.send_result( + msg["id"], + { + "cost_sensors": hass.data[DOMAIN]["cost_sensors"], + "solar_forecast_domains": list(forecast_platforms), + }, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/validate", + } +) +@websocket_api.async_response +async def ws_validate( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle validate command.""" + connection.send_result(msg["id"], (await async_validate(hass)).as_dict()) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/solar_forecast", + } +) +@_ws_with_manager +async def ws_solar_forecast( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + manager: EnergyManager, +) -> None: + """Handle solar forecast command.""" + if manager.data is None: + connection.send_result(msg["id"], {}) + return + + config_entries: dict[str, str | None] = {} + + for source in manager.data["energy_sources"]: + if ( + source["type"] != "solar" + or source.get("config_entry_solar_forecast") is None + ): + continue + + # typing is not catching the above guard for config_entry_solar_forecast being none + for config_entry in source["config_entry_solar_forecast"]: # type: ignore[union-attr] + config_entries[config_entry] = None + + if not config_entries: + connection.send_result(msg["id"], {}) + return + + forecasts = {} + + forecast_platforms = await async_get_energy_platforms(hass) + + for config_entry_id in config_entries: + config_entry = hass.config_entries.async_get_entry(config_entry_id) + # Filter out non-existing config entries or unsupported domains + + if config_entry is None or config_entry.domain not in forecast_platforms: + continue + + forecast = await forecast_platforms[config_entry.domain](hass, config_entry_id) + + if forecast is not None: + forecasts[config_entry_id] = forecast + + connection.send_result(msg["id"], forecasts) diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 1814efb9c87..ccf01eec448 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -1,7 +1,13 @@ """Support for EnOcean sensors.""" +from __future__ import annotations + 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_DEVICE_CLASS, CONF_ID, @@ -32,32 +38,36 @@ SENSOR_TYPE_POWER = "powersensor" SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPE_WINDOWHANDLE = "windowhandle" -SENSOR_TYPES = { - SENSOR_TYPE_HUMIDITY: { - "name": "Humidity", - "unit": PERCENTAGE, - "icon": "mdi:water-percent", - "class": DEVICE_CLASS_HUMIDITY, - }, - SENSOR_TYPE_POWER: { - "name": "Power", - "unit": POWER_WATT, - "icon": "mdi:power-plug", - "class": DEVICE_CLASS_POWER, - }, - SENSOR_TYPE_TEMPERATURE: { - "name": "Temperature", - "unit": TEMP_CELSIUS, - "icon": "mdi:thermometer", - "class": DEVICE_CLASS_TEMPERATURE, - }, - SENSOR_TYPE_WINDOWHANDLE: { - "name": "WindowHandle", - "unit": None, - "icon": "mdi:window", - "class": None, - }, -} +SENSOR_DESC_TEMPERATURE = SensorEntityDescription( + key=SENSOR_TYPE_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=DEVICE_CLASS_TEMPERATURE, +) + +SENSOR_DESC_HUMIDITY = SensorEntityDescription( + key=SENSOR_TYPE_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + device_class=DEVICE_CLASS_HUMIDITY, +) + +SENSOR_DESC_POWER = SensorEntityDescription( + key=SENSOR_TYPE_POWER, + name="Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:power-plug", + device_class=DEVICE_CLASS_POWER, +) + +SENSOR_DESC_WINDOWHANDLE = SensorEntityDescription( + key=SENSOR_TYPE_WINDOWHANDLE, + name="WindowHandle", + icon="mdi:window", +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -74,81 +84,60 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up an EnOcean sensor device.""" - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) - sensor_type = config.get(CONF_DEVICE_CLASS) + dev_id = config[CONF_ID] + dev_name = config[CONF_NAME] + sensor_type = config[CONF_DEVICE_CLASS] + entities: list[EnOceanSensor] = [] if sensor_type == SENSOR_TYPE_TEMPERATURE: - temp_min = config.get(CONF_MIN_TEMP) - temp_max = config.get(CONF_MAX_TEMP) - range_from = config.get(CONF_RANGE_FROM) - range_to = config.get(CONF_RANGE_TO) - add_entities( - [ - EnOceanTemperatureSensor( - dev_id, dev_name, temp_min, temp_max, range_from, range_to - ) - ] - ) + temp_min = config[CONF_MIN_TEMP] + temp_max = config[CONF_MAX_TEMP] + range_from = config[CONF_RANGE_FROM] + range_to = config[CONF_RANGE_TO] + entities = [ + EnOceanTemperatureSensor( + dev_id, + dev_name, + SENSOR_DESC_TEMPERATURE, + scale_min=temp_min, + scale_max=temp_max, + range_from=range_from, + range_to=range_to, + ) + ] elif sensor_type == SENSOR_TYPE_HUMIDITY: - add_entities([EnOceanHumiditySensor(dev_id, dev_name)]) + entities = [EnOceanHumiditySensor(dev_id, dev_name, SENSOR_DESC_HUMIDITY)] elif sensor_type == SENSOR_TYPE_POWER: - add_entities([EnOceanPowerSensor(dev_id, dev_name)]) + entities = [EnOceanPowerSensor(dev_id, dev_name, SENSOR_DESC_POWER)] elif sensor_type == SENSOR_TYPE_WINDOWHANDLE: - add_entities([EnOceanWindowHandle(dev_id, dev_name)]) + entities = [EnOceanWindowHandle(dev_id, dev_name, SENSOR_DESC_WINDOWHANDLE)] + + if entities: + add_entities(entities) class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Representation of an EnOcean sensor device such as a power meter.""" - def __init__(self, dev_id, dev_name, sensor_type): + def __init__(self, dev_id, dev_name, description: SensorEntityDescription): """Initialize the EnOcean sensor device.""" super().__init__(dev_id, dev_name) - self._sensor_type = sensor_type - self._device_class = SENSOR_TYPES[self._sensor_type]["class"] - self._dev_name = f"{SENSOR_TYPES[self._sensor_type]['name']} {dev_name}" - self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]["unit"] - self._icon = SENSOR_TYPES[self._sensor_type]["icon"] - self._state = None - - @property - def name(self): - """Return the name of the device.""" - return self._dev_name - - @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + self.entity_description = description + self._attr_name = f"{description.name} {dev_name}" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. await super().async_added_to_hass() - if self._state is not None: + if self._attr_native_value is not None: return state = await self.async_get_last_state() if state is not None: - self._state = state.state + self._attr_native_value = state.state def value_changed(self, packet): """Update the internal state of the sensor.""" @@ -161,10 +150,6 @@ class EnOceanPowerSensor(EnOceanSensor): - A5-12-01 (Automated Meter Reading, Electricity) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean power sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_POWER) - def value_changed(self, packet): """Update the internal state of the sensor.""" if packet.rorg != 0xA5: @@ -174,7 +159,7 @@ class EnOceanPowerSensor(EnOceanSensor): # this packet reports the current value raw_val = packet.parsed["MR"]["raw_value"] divisor = packet.parsed["DIV"]["raw_value"] - self._state = raw_val / (10 ** divisor) + self._attr_native_value = raw_val / (10 ** divisor) self.schedule_update_ha_state() @@ -196,9 +181,19 @@ class EnOceanTemperatureSensor(EnOceanSensor): - A5-10-10 to A5-10-14 """ - def __init__(self, dev_id, dev_name, scale_min, scale_max, range_from, range_to): + def __init__( + self, + dev_id, + dev_name, + description: SensorEntityDescription, + *, + scale_min, + scale_max, + range_from, + range_to, + ): """Initialize the EnOcean temperature sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_TEMPERATURE) + super().__init__(dev_id, dev_name, description) self._scale_min = scale_min self._scale_max = scale_max self.range_from = range_from @@ -213,7 +208,7 @@ class EnOceanTemperatureSensor(EnOceanSensor): raw_val = packet.data[3] temperature = temp_scale / temp_range * (raw_val - self.range_from) temperature += self._scale_min - self._state = round(temperature, 1) + self._attr_native_value = round(temperature, 1) self.schedule_update_ha_state() @@ -226,16 +221,12 @@ class EnOceanHumiditySensor(EnOceanSensor): - A5-10-10 to A5-10-14 (Room Operating Panels) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean humidity sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_HUMIDITY) - def value_changed(self, packet): """Update the internal state of the sensor.""" if packet.rorg != 0xA5: return humidity = packet.data[2] * 100 / 250 - self._state = round(humidity, 1) + self._attr_native_value = round(humidity, 1) self.schedule_update_ha_state() @@ -246,20 +237,16 @@ class EnOceanWindowHandle(EnOceanSensor): - F6-10-00 (Mechanical handle / Hoppe AG) """ - def __init__(self, dev_id, dev_name): - """Initialize the EnOcean window handle sensor device.""" - super().__init__(dev_id, dev_name, SENSOR_TYPE_WINDOWHANDLE) - def value_changed(self, packet): """Update the internal state of the sensor.""" action = (packet.data[1] & 0x70) >> 4 if action == 0x07: - self._state = STATE_CLOSED + self._attr_native_value = STATE_CLOSED if action in (0x04, 0x06): - self._state = STATE_OPEN + self._attr_native_value = STATE_OPEN if action == 0x05: - self._state = "tilt" + self._attr_native_value = "tilt" self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/translations/es-419.json b/homeassistant/components/enocean/translations/es-419.json new file mode 100644 index 00000000000..a0eaca491b2 --- /dev/null +++ b/homeassistant/components/enocean/translations/es-419.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "Ruta de dongle no v\u00e1lida" + }, + "error": { + "invalid_dongle_path": "No se encontr\u00f3 ning\u00fan dongle v\u00e1lido para esta ruta" + }, + "step": { + "detect": { + "data": { + "path": "Ruta de dongle USB" + }, + "title": "Seleccione la ruta a su ENOcean dongle" + }, + "manual": { + "data": { + "path": "Ruta de dongle USB" + }, + "title": "Ingrese la ruta a su ENOcean dongle" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/hu.json b/homeassistant/components/enocean/translations/hu.json index 065747fb39d..bfb6cb0499d 100644 --- a/homeassistant/components/enocean/translations/hu.json +++ b/homeassistant/components/enocean/translations/hu.json @@ -1,7 +1,25 @@ { "config": { "abort": { + "invalid_dongle_path": "\u00c9rv\u00e9nytelen dongle \u00fatvonal", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "error": { + "invalid_dongle_path": "Nem tal\u00e1lhat\u00f3 \u00e9rv\u00e9nyes dongle ehhez az \u00fatvonalhoz" + }, + "step": { + "detect": { + "data": { + "path": "USB dongle el\u00e9r\u00e9si \u00fatja" + }, + "title": "V\u00e1lassza ki az ENOcean-dongle el\u00e9r\u00e9si \u00fatvonal\u00e1t." + }, + "manual": { + "data": { + "path": "USB dongle el\u00e9r\u00e9si \u00fatja" + }, + "title": "Adja meg az ENOcean dongle el\u00e9r\u00e9si \u00fatvonal\u00e1t" + } } } } \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 9f87a821787..ff42ef23746 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -3,10 +3,10 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntityDescription, ) from homeassistant.const import DEVICE_CLASS_ENERGY, ENERGY_WATT_HOUR, POWER_WATT -from homeassistant.util import dt DOMAIN = "enphase_envoy" @@ -20,63 +20,61 @@ SENSORS = ( SensorEntityDescription( key="production", name="Current Power Production", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="daily_production", name="Today's Energy Production", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( key="seven_days_production", name="Last Seven Days Energy Production", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( key="lifetime_production", name="Lifetime Energy Production", - unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), ), SensorEntityDescription( key="consumption", name="Current Power Consumption", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key="daily_consumption", name="Today's Energy Consumption", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( key="seven_days_consumption", name="Last Seven Days Energy Consumption", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, state_class=STATE_CLASS_MEASUREMENT, device_class=DEVICE_CLASS_ENERGY, ), SensorEntityDescription( key="lifetime_consumption", name="Lifetime Energy Consumption", - unit_of_measurement=ENERGY_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, device_class=DEVICE_CLASS_ENERGY, - last_reset=dt.utc_from_timestamp(0), ), SensorEntityDescription( key="inverters", name="Inverter", - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 3af5cd1ec0c..9bf4073847e 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -132,7 +132,7 @@ class Envoy(CoordinatorEntity, SensorEntity): return f"{self._device_serial_number}_{self.entity_description.key}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.entity_description.key != "inverters": value = self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/enphase_envoy/translations/es-419.json b/homeassistant/components/enphase_envoy/translations/es-419.json new file mode 100644 index 00000000000..3dd80c3f60b --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/es-419.json @@ -0,0 +1,5 @@ +{ + "config": { + "flow_title": "{serial} ({host})" + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/zh-Hans.json b/homeassistant/components/enphase_envoy/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 0852f95bd99..cad8a49884f 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -168,7 +168,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._name @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self._state @@ -180,7 +180,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._attributes @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 019dcb1aee5..ecd0c562d16 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,4 +1,6 @@ """Support for the Environment Canada radar imagery.""" +from __future__ import annotations + import datetime from env_canada import ECRadar @@ -68,7 +70,9 @@ class ECCamera(Camera): self.image = None self.timestamp = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" self.update() return self.image diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 232bc558da1..3690703d8d2 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -91,7 +91,7 @@ class ECSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -101,7 +101,7 @@ class ECSensor(SensorEntity): return self._attr @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return self._unit @@ -137,10 +137,10 @@ class ECSensor(SensorEntity): else: self._state = value - if sensor_data.get("unit") == "C" or self.sensor_type in [ + if sensor_data.get("unit") == "C" or self.sensor_type in ( "wind_chill", "humidex", - ]: + ): self._unit = TEMP_CELSIUS self._device_class = DEVICE_CLASS_TEMPERATURE else: diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py index 9bca552326a..cff3e95f355 100644 --- a/homeassistant/components/envirophat/sensor.py +++ b/homeassistant/components/envirophat/sensor.py @@ -1,11 +1,17 @@ """Support for Enviro pHAT sensors.""" +from __future__ import annotations + from datetime import timedelta import importlib 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_DISPLAY_OPTIONS, CONF_NAME, @@ -24,30 +30,103 @@ CONF_USE_LEDS = "use_leds" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -SENSOR_TYPES = { - "light": ["light", " ", "mdi:weather-sunny", None], - "light_red": ["light_red", " ", "mdi:invert-colors", None], - "light_green": ["light_green", " ", "mdi:invert-colors", None], - "light_blue": ["light_blue", " ", "mdi:invert-colors", None], - "accelerometer_x": ["accelerometer_x", "G", "mdi:earth", None], - "accelerometer_y": ["accelerometer_y", "G", "mdi:earth", None], - "accelerometer_z": ["accelerometer_z", "G", "mdi:earth", None], - "magnetometer_x": ["magnetometer_x", " ", "mdi:magnet", None], - "magnetometer_y": ["magnetometer_y", " ", "mdi:magnet", None], - "magnetometer_z": ["magnetometer_z", " ", "mdi:magnet", None], - "temperature": ["temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "pressure": ["pressure", PRESSURE_HPA, "mdi:gauge", None], - "voltage_0": ["voltage_0", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "voltage_1": ["voltage_1", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "voltage_2": ["voltage_2", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], - "voltage_3": ["voltage_3", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="light", + name="light", + icon="mdi:weather-sunny", + ), + SensorEntityDescription( + key="light_red", + name="light_red", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="light_green", + name="light_green", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="light_blue", + name="light_blue", + icon="mdi:invert-colors", + ), + SensorEntityDescription( + key="accelerometer_x", + name="accelerometer_x", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="accelerometer_y", + name="accelerometer_y", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="accelerometer_z", + name="accelerometer_z", + native_unit_of_measurement="G", + icon="mdi:earth", + ), + SensorEntityDescription( + key="magnetometer_x", + name="magnetometer_x", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="magnetometer_y", + name="magnetometer_y", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="magnetometer_z", + name="magnetometer_z", + icon="mdi:magnet", + ), + SensorEntityDescription( + key="temperature", + name="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="pressure", + name="pressure", + native_unit_of_measurement=PRESSURE_HPA, + icon="mdi:gauge", + ), + SensorEntityDescription( + key="voltage_0", + name="voltage_0", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_1", + name="voltage_1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_2", + name="voltage_2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), + SensorEntityDescription( + key="voltage_3", + name="voltage_3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + icon="mdi:flash", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [ - vol.In(SENSOR_TYPES) - ], + vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_KEYS): [vol.In(SENSOR_KEYS)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_USE_LEDS, default=False): cv.boolean, } @@ -64,85 +143,60 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = EnvirophatData(envirophat, config.get(CONF_USE_LEDS)) - dev = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - dev.append(EnvirophatSensor(data, variable)) - - add_entities(dev, True) + display_options = config[CONF_DISPLAY_OPTIONS] + entities = [ + EnvirophatSensor(data, description) + for description in SENSOR_TYPES + if description.key in display_options + ] + add_entities(entities, True) class EnvirophatSensor(SensorEntity): """Representation of an Enviro pHAT sensor.""" - def __init__(self, data, sensor_types): + def __init__(self, data, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = SENSOR_TYPES[sensor_types][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_types][1] - self.type = sensor_types - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES[self.type][3] - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement def update(self): """Get the latest data and updates the states.""" self.data.update() - if self.type == "light": - self._state = self.data.light - if self.type == "light_red": - self._state = self.data.light_red - if self.type == "light_green": - self._state = self.data.light_green - if self.type == "light_blue": - self._state = self.data.light_blue - if self.type == "accelerometer_x": - self._state = self.data.accelerometer_x - if self.type == "accelerometer_y": - self._state = self.data.accelerometer_y - if self.type == "accelerometer_z": - self._state = self.data.accelerometer_z - if self.type == "magnetometer_x": - self._state = self.data.magnetometer_x - if self.type == "magnetometer_y": - self._state = self.data.magnetometer_y - if self.type == "magnetometer_z": - self._state = self.data.magnetometer_z - if self.type == "temperature": - self._state = self.data.temperature - if self.type == "pressure": - self._state = self.data.pressure - if self.type == "voltage_0": - self._state = self.data.voltage_0 - if self.type == "voltage_1": - self._state = self.data.voltage_1 - if self.type == "voltage_2": - self._state = self.data.voltage_2 - if self.type == "voltage_3": - self._state = self.data.voltage_3 + sensor_type = self.entity_description.key + if sensor_type == "light": + self._attr_native_value = self.data.light + elif sensor_type == "light_red": + self._attr_native_value = self.data.light_red + elif sensor_type == "light_green": + self._attr_native_value = self.data.light_green + elif sensor_type == "light_blue": + self._attr_native_value = self.data.light_blue + elif sensor_type == "accelerometer_x": + self._attr_native_value = self.data.accelerometer_x + elif sensor_type == "accelerometer_y": + self._attr_native_value = self.data.accelerometer_y + elif sensor_type == "accelerometer_z": + self._attr_native_value = self.data.accelerometer_z + elif sensor_type == "magnetometer_x": + self._attr_native_value = self.data.magnetometer_x + elif sensor_type == "magnetometer_y": + self._attr_native_value = self.data.magnetometer_y + elif sensor_type == "magnetometer_z": + self._attr_native_value = self.data.magnetometer_z + elif sensor_type == "temperature": + self._attr_native_value = self.data.temperature + elif sensor_type == "pressure": + self._attr_native_value = self.data.pressure + elif sensor_type == "voltage_0": + self._attr_native_value = self.data.voltage_0 + elif sensor_type == "voltage_1": + self._attr_native_value = self.data.voltage_1 + elif sensor_type == "voltage_2": + self._attr_native_value = self.data.voltage_2 + elif sensor_type == "voltage_3": + self._attr_native_value = self.data.voltage_3 class EnvirophatData: diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py index 6fd7f32c6fe..88aa7fa988c 100644 --- a/homeassistant/components/envisalink/sensor.py +++ b/homeassistant/components/envisalink/sensor.py @@ -61,7 +61,7 @@ class EnvisalinkSensor(EnvisalinkDevice, SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the overall state.""" return self._info["status"]["alpha"] diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py index 5203cdbe9e0..b1ac34b1099 100644 --- a/homeassistant/components/epson/config_flow.py +++ b/homeassistant/components/epson/config_flow.py @@ -25,30 +25,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - for entry in self._async_current_entries(include_ignore=True): - if import_config[CONF_HOST] == entry.data[CONF_HOST]: - return self.async_abort(reason="already_configured") - try: - projector = await validate_projector( - hass=self.hass, - host=import_config[CONF_HOST], - check_power=True, - check_powered_on=False, - ) - except CannotConnect: - _LOGGER.warning("Cannot connect to projector") - return self.async_abort(reason="cannot_connect") - - serial_no = await projector.get_serial_number() - await self.async_set_unique_id(serial_no) - self._abort_if_unique_id_configured() - import_config.pop(CONF_PORT, None) - return self.async_create_entry( - title=import_config.pop(CONF_NAME), data=import_config - ) - async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} diff --git a/homeassistant/components/epson/const.py b/homeassistant/components/epson/const.py index 9b1ad0a8f5f..06ef9f25e35 100644 --- a/homeassistant/components/epson/const.py +++ b/homeassistant/components/epson/const.py @@ -4,5 +4,4 @@ DOMAIN = "epson" SERVICE_SELECT_CMODE = "select_cmode" ATTR_CMODE = "cmode" -DEFAULT_NAME = "EPSON Projector" HTTP = "http" diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 92a43330d69..1fd0b7f6e70 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -26,7 +26,7 @@ from epson_projector.const import ( ) import voluptuous as vol -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, @@ -36,13 +36,12 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry -from .const import ATTR_CMODE, DEFAULT_NAME, DOMAIN, SERVICE_SELECT_CMODE +from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE _LOGGER = logging.getLogger(__name__) @@ -56,14 +55,6 @@ SUPPORT_EPSON = ( | SUPPORT_PREVIOUS_TRACK ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=80): cv.port, - } -) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Epson projector from a config entry.""" @@ -85,19 +76,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Epson projector.""" - _LOGGER.warning( - "Loading Espon projector via platform setup is deprecated; " - "Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - class EpsonProjectorMediaPlayer(MediaPlayerEntity): """Representation of Epson Projector Device.""" diff --git a/homeassistant/components/epson/translations/es-419.json b/homeassistant/components/epson/translations/es-419.json new file mode 100644 index 00000000000..230dada00f7 --- /dev/null +++ b/homeassistant/components/epson/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "powered_off": "\u00bfEst\u00e1 encendido el proyector? Debe encender el proyector para la configuraci\u00f3n inicial." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/zh-Hans.json b/homeassistant/components/epson/translations/zh-Hans.json new file mode 100644 index 00000000000..3cb7f97ceb9 --- /dev/null +++ b/homeassistant/components/epson/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "powered_off": "\u6295\u5f71\u4eea\u662f\u5426\u5df2\u7ecf\u6253\u5f00\uff1f\u60a8\u9700\u8981\u6253\u5f00\u6295\u5f71\u4eea\u4ee5\u8fdb\u884c\u521d\u59cb\u914d\u7f6e\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py index 2f483b9fcbf..285f2fc83e7 100644 --- a/homeassistant/components/epsonworkforce/sensor.py +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -20,37 +20,37 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="black", name="Ink level Black", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="photoblack", name="Ink level Photoblack", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="magenta", name="Ink level Magenta", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="cyan", name="Ink level Cyan", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="yellow", name="Ink level Yellow", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="clean", name="Cleaning level", icon="mdi:water", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), ) MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] @@ -92,7 +92,7 @@ class EpsonPrinterCartridge(SensorEntity): self.entity_description = description @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._api.getSensorValue(self.entity_description.key) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 2efe005230f..2e33742b8e5 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -978,3 +978,8 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): def should_poll(self) -> bool: """Disable polling.""" return False + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return not self._static_info.disabled_by_default diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 938d78362f7..47010324290 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -50,7 +50,9 @@ class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): async with self._image_cond: self._image_cond.notify_all() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return single camera image bytes.""" if not self.available: return None diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 247484ba317..940fee11076 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -111,10 +111,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): for entry in self._async_current_entries(): already_configured = False - if CONF_HOST in entry.data and entry.data[CONF_HOST] in [ + if CONF_HOST in entry.data and entry.data[CONF_HOST] in ( address, discovery_info[CONF_HOST], - ]: + ): # Is this address or IP address already configured? already_configured = True elif DomainData.get(self.hass).is_entry_loaded(entry): diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index c6cf9742082..9e7f544f610 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, cast -from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState +from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -34,12 +34,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -59,20 +54,81 @@ async def async_setup_entry( ) -_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper( - { - LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN, - LightColorMode.ON_OFF: COLOR_MODE_ONOFF, - LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS, - LightColorMode.WHITE: COLOR_MODE_WHITE, - LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP, - LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP, - LightColorMode.RGB: COLOR_MODE_RGB, - LightColorMode.RGB_WHITE: COLOR_MODE_RGBW, - LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW, - LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW, - } -) +_COLOR_MODE_MAPPING = { + COLOR_MODE_ONOFF: [ + LightColorCapability.ON_OFF, + ], + COLOR_MODE_BRIGHTNESS: [ + LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + # for compatibility with older clients (2021.8.x) + LightColorCapability.BRIGHTNESS, + ], + COLOR_MODE_COLOR_TEMP: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_RGB: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB, + ], + COLOR_MODE_RGBW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE, + ], + COLOR_MODE_RGBWW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_WHITE: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ], +} + + +def _color_mode_to_ha(mode: int) -> str: + """Convert an esphome color mode to a HA color mode constant. + + Choses the color mode that best matches the feature-set. + """ + candidates = [] + for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): + for caps in cap_lists: + if caps == mode: + # exact match + return ha_mode + if (mode & caps) == caps: + # all requirements met + candidates.append((ha_mode, caps)) + + if not candidates: + return COLOR_MODE_UNKNOWN + + # choose the color mode with the most bits set + candidates.sort(key=lambda key: bin(key[1]).count("1")) + return candidates[-1][0] + + +def _filter_color_modes( + supported: list[int], features: LightColorCapability +) -> list[int]: + """Filter the given supported color modes, excluding all values that don't have the requested features.""" + return [mode for mode in supported if mode & features] # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property @@ -95,10 +151,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" data: dict[str, Any] = {"key": self._static_info.key, "state": True} + # The list of color modes that would fit this service call + color_modes = self._native_supported_color_modes + try_keep_current_mode = True + # rgb/brightness input is in range 0-255, but esphome uses 0-1 if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None: data["brightness"] = brightness_ha / 255 + color_modes = _filter_color_modes( + color_modes, LightColorCapability.BRIGHTNESS + ) if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None: rgb = tuple(x / 255 for x in rgb_ha) @@ -106,8 +169,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + try_keep_current_mode = False if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: # pylint: disable=invalid-name @@ -117,8 +180,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["white"] = w data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.RGB | LightColorCapability.WHITE + ) + try_keep_current_mode = False if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: # pylint: disable=invalid-name @@ -126,14 +191,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): color_bri = max(rgb) # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) - modes = self._native_supported_color_modes - if ( - self._supports_color_mode - and LightColorMode.RGB_COLD_WARM_WHITE in modes - ): + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE): + # Device supports setting cwww values directly data["cold_white"] = cw data["warm_white"] = ww - target_mode = LightColorMode.RGB_COLD_WARM_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) else: # need to convert cw+ww part to white+color_temp white = data["white"] = max(cw, ww) @@ -142,11 +207,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): max_ct = self.max_mireds ct_ratio = ww / (cw + ww) data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) - target_mode = LightColorMode.RGB_COLOR_TEMPERATURE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE, + ) + try_keep_current_mode = False data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = target_mode if (flash := kwargs.get(ATTR_FLASH)) is not None: data["flash_length"] = FLASH_LENGTHS[flash] @@ -156,8 +223,15 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: data["color_temperature"] = color_temp - if self._supports_color_mode: - data["color_mode"] = LightColorMode.COLOR_TEMPERATURE + if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ) + else: + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) + try_keep_current_mode = False if (effect := kwargs.get(ATTR_EFFECT)) is not None: data["effect"] = effect @@ -167,7 +241,30 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # HA only sends `white` in turn_on, and reads total brightness through brightness property data["brightness"] = white_ha / 255 data["white"] = 1.0 - data["color_mode"] = LightColorMode.WHITE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + ) + 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 + and self._state is not None + and self._state.color_mode in color_modes + ): + # if possible, stay with the color mode that is already set + data["color_mode"] = self._state.color_mode + else: + # otherwise 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] await self._client.light_command(**data) @@ -194,7 +291,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return None return next(iter(supported)) - return _COLOR_MODES.from_esphome(self._state.color_mode) + return _color_mode_to_ha(self._state.color_mode) @esphome_state_property def rgb_color(self) -> tuple[int, int, int] | None: @@ -223,9 +320,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" rgb = cast("tuple[int, int, int]", self.rgb_color) - if ( - not self._supports_color_mode - or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE + if not _filter_color_modes( + self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE ): # Try to reverse white + color temp to cwww min_ct = self._static_info.min_mireds @@ -258,7 +354,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return self._state.effect @property - def _native_supported_color_modes(self) -> list[LightColorMode]: + def _native_supported_color_modes(self) -> list[int]: return self._static_info.supported_color_modes_compat(self._api_version) @property @@ -268,7 +364,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # All color modes except UNKNOWN,ON_OFF support transition modes = self._native_supported_color_modes - if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes): + if any(m not in (0, LightColorCapability.ON_OFF) for m in modes): flags |= SUPPORT_TRANSITION if self._static_info.effects: flags |= SUPPORT_EFFECT @@ -277,7 +373,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property def supported_color_modes(self) -> set[str] | None: """Flag supported color modes.""" - return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes)) + supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) + if COLOR_MODE_ONOFF in supported and len(supported) > 1: + supported.remove(COLOR_MODE_ONOFF) + if COLOR_MODE_BRIGHTNESS in supported and len(supported) > 1: + supported.remove(COLOR_MODE_BRIGHTNESS) + if COLOR_MODE_WHITE in supported and len(supported) == 1: + supported.remove(COLOR_MODE_WHITE) + return supported @property def effect_list(self) -> list[str]: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 22fa33091fd..a78d2efb763 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==6.0.1"], + "requirements": ["aioesphomeapi==8.0.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 6a2b51498f0..c0ea9f0f9c5 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,8 +1,6 @@ """Support for esphome sensors.""" from __future__ import annotations -from contextlib import suppress -from datetime import datetime import math from typing import cast @@ -20,13 +18,13 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TIMESTAMP, DEVICE_CLASSES, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt from . import ( @@ -71,83 +69,14 @@ _STATE_CLASSES: EsphomeEnumMapper[SensorStateClass, str | None] = EsphomeEnumMap { SensorStateClass.NONE: None, SensorStateClass.MEASUREMENT: STATE_CLASS_MEASUREMENT, + SensorStateClass.TOTAL_INCREASING: STATE_CLASS_TOTAL_INCREASING, } ) -class EsphomeSensor( - EsphomeEntity[SensorInfo, SensorState], SensorEntity, RestoreEntity -): +class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): """A sensor implementation for esphome.""" - _old_state: float | None = None - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - if self._static_info.last_reset_type != LastResetType.AUTO: - return - - # Logic to restore old state for last_reset_type AUTO: - last_state = await self.async_get_last_state() - if last_state is None: - return - - if "last_reset" in last_state.attributes: - self._attr_last_reset = dt.as_utc( - datetime.fromisoformat(last_state.attributes["last_reset"]) - ) - - with suppress(ValueError): - self._old_state = float(last_state.state) - - @callback - def _on_state_update(self) -> None: - """Check last_reset when new state arrives.""" - if self._static_info.last_reset_type == LastResetType.NEVER: - self._attr_last_reset = dt.utc_from_timestamp(0) - - if self._static_info.last_reset_type != LastResetType.AUTO: - super()._on_state_update() - return - - # Last reset type AUTO logic for the last_reset property - # In this mode we automatically determine if an accumulator reset - # has taken place. - # We compare the last valid value (_old_state) with the new one. - # If the value has reset to 0 or has significantly reduced we say - # it has reset. - new_state: float | None = None - state = cast("str | None", self.state) - if state is not None: - with suppress(ValueError): - new_state = float(state) - - did_reset = False - if new_state is None: - # New state is not a float - we'll detect the reset once we get valid data again - did_reset = False - elif self._old_state is None: - # First measurement we ever got for this sensor, always a reset - did_reset = True - elif new_state == 0: - # don't set reset if both old and new are 0 - # we would already have detected the reset on the last state - did_reset = self._old_state != 0 - elif new_state < self._old_state: - did_reset = True - - # Set last_reset to now if we detected a reset - if did_reset: - self._attr_last_reset = dt.utcnow() - - if new_state is not None: - # Only write to old_state if the new one contains actual data - self._old_state = new_state - - super()._on_state_update() - @property def icon(self) -> str | None: """Return the icon.""" @@ -161,7 +90,7 @@ class EsphomeSensor( return self._static_info.force_update @esphome_state_property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" if math.isnan(self._state.state): return None @@ -172,7 +101,7 @@ class EsphomeSensor( return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" if not self._static_info.unit_of_measurement: return None @@ -190,6 +119,14 @@ class EsphomeSensor( """Return the state class of this entity.""" if not self._static_info.state_class: return None + state_class = self._static_info.state_class + reset_type = self._static_info.last_reset_type + if ( + state_class == SensorStateClass.MEASUREMENT + and reset_type == LastResetType.AUTO + ): + # Legacy, last_reset_type auto was the equivalent to the TOTAL_INCREASING state class + return STATE_CLASS_TOTAL_INCREASING return _STATE_CLASSES.from_esphome(self._static_info.state_class) @@ -202,7 +139,7 @@ class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEn return self._static_info.icon @esphome_state_property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" if self._state.missing_state: return None diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py index f0dc70d7be4..42a4c1c399b 100644 --- a/homeassistant/components/essent/sensor.py +++ b/homeassistant/components/essent/sensor.py @@ -104,12 +104,12 @@ class EssentMeter(SensorEntity): return f"Essent {self._type} ({self._tariff})" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self._unit.lower() == "kwh": return ENERGY_KILO_WATT_HOUR diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index 1b10cc39fe1..b1ec3cddb0c 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -59,12 +59,12 @@ class EtherscanSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 045a742485b..cec59742992 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -531,7 +531,7 @@ class EvoDevice(Entity): return if payload["unique_id"] != self._unique_id: return - if payload["service"] in [SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE]: + if payload["service"] in (SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE): await self.async_zone_svc_request(payload["service"], payload["data"]) return await self.async_tcs_svc_request(payload["service"], payload["data"]) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 8021ad6ba24..6dc2809630d 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -194,7 +194,7 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def hvac_mode(self) -> str: """Return the current operating mode of a Zone.""" - if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): return HVAC_MODE_AUTO is_off = self.target_temperature <= self.min_temp return HVAC_MODE_OFF if is_off else HVAC_MODE_HEAT @@ -207,7 +207,7 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 76fbaee3757..44a90e2928f 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -1,13 +1,12 @@ """Support ezviz camera devices.""" from __future__ import annotations -import asyncio import logging -from haffmpeg.tools import IMAGE_JPEG, ImageFrame from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.config_entries import ( @@ -325,14 +324,15 @@ class EzvizCamera(CoordinatorEntity, Camera): """Return the name of this camera.""" return self._serial - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a frame from the camera stream.""" - ffmpeg = ImageFrame(self._ffmpeg.binary) - - image = await asyncio.shield( - ffmpeg.get_image(self._rtsp_stream, output_format=IMAGE_JPEG) + if self._rtsp_stream is None: + return None + return await ffmpeg.async_get_image( + self.hass, self._rtsp_stream, width=width, height=height ) - return image @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 4e81ef6a6a7..512491a2548 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -5,9 +5,10 @@ import logging from pyezviz.constants import SensorType +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -39,7 +40,7 @@ async def async_setup_entry( async_add_entities(sensors) -class EzvizSensor(CoordinatorEntity, Entity): +class EzvizSensor(CoordinatorEntity, SensorEntity): """Representation of a Ezviz sensor.""" coordinator: EzvizDataUpdateCoordinator @@ -66,7 +67,7 @@ class EzvizSensor(CoordinatorEntity, Entity): return self._name @property - def state(self) -> int | str: + def native_value(self) -> int | str: """Return the state of the sensor.""" return self.coordinator.data[self._idx][self._name] diff --git a/homeassistant/components/ezviz/translations/es-419.json b/homeassistant/components/ezviz/translations/es-419.json new file mode 100644 index 00000000000..376cb65c383 --- /dev/null +++ b/homeassistant/components/ezviz/translations/es-419.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "ezviz_cloud_account_missing": "Falta la cuenta en la nube de Ezviz. Vuelva a configurar la cuenta en la nube de Ezviz" + }, + "flow_title": "{serial}" + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/zh-Hans.json b/homeassistant/components/ezviz/translations/zh-Hans.json new file mode 100644 index 00000000000..3d8daedec73 --- /dev/null +++ b/homeassistant/components/ezviz/translations/zh-Hans.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", + "ezviz_cloud_account_missing": "\u8424\u77f3\u4e91\u8d26\u53f7\u4e22\u5931\u3002\u8bf7\u91cd\u65b0\u914d\u7f6e\u8424\u77f3\u4e91\u8d26\u53f7\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u51ed\u8bc1\u65e0\u6548", + "invalid_host": "\u65e0\u6548\u7684\u4e3b\u673a\u5730\u5740\u6216 IP \u5730\u5740" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8f93\u5165\u5e26\u6709 RTSP \u51ed\u8bc1\u7684\u8424\u77f3\u6444\u50cf\u5934{serial} IP {ip_address} ", + "title": "\u5df2\u53d1\u73b0\u7684\u8424\u77f3\u6444\u50cf\u5934" + }, + "user": { + "data": { + "password": "\u5bc6\u7801", + "url": "URL", + "username": "\u7528\u6237\u540d" + }, + "title": "\u8fde\u63a5\u5230\u8424\u77f3\u4e91" + }, + "user_custom_url": { + "data": { + "password": "\u5bc6\u7801", + "url": "URL", + "username": "\u7528\u6237\u540d" + }, + "description": "\u624b\u52a8\u6307\u5b9a\u4f60\u7684\u533a\u57df\u7f51\u5740", + "title": "\u8fde\u63a5\u5230\u81ea\u5b9a\u4e49\u8424\u77f3\u4e91\u5730\u5740" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "FFmpeg \u53c2\u6570\u4f20\u9012\u81f3\u6444\u50cf\u673a", + "timeout": "\u8bf7\u6c42\u8d85\u65f6\uff08\u79d2\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/es-419.json b/homeassistant/components/faa_delays/translations/es-419.json new file mode 100644 index 00000000000..838f7af274d --- /dev/null +++ b/homeassistant/components/faa_delays/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Este aeropuerto ya est\u00e1 configurado." + }, + "error": { + "invalid_airport": "El c\u00f3digo del aeropuerto no es v\u00e1lido" + }, + "step": { + "user": { + "data": { + "id": "Aeropuerto" + }, + "description": "Ingrese un c\u00f3digo de aeropuerto de EE. UU. en formato IATA", + "title": "Retrasos de la FAA" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 908ab5d77c0..5a7e1052b67 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -70,7 +70,7 @@ class BanSensor(SensorEntity): return self.ban_dict @property - def state(self): + def native_value(self): """Return the most recently banned IP Address.""" return self.last_ban diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index ea654074a5a..65b7a63e419 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -1,4 +1,6 @@ """Family Hub camera for Samsung Refrigerators.""" +from __future__ import annotations + from pyfamilyhublocal import FamilyHubCam import voluptuous as vol @@ -38,7 +40,9 @@ class FamilyHubCamera(Camera): self._name = name self.family_hub_cam = family_hub_cam - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response.""" return await self.family_hub_cam.async_get_cam_image() diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 1d0caa3231b..a05505e8112 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -25,6 +25,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.percentage import ( ordered_list_item_to_percentage, @@ -124,7 +125,7 @@ def is_on(hass, entity_id: str) -> bool: return state.state == STATE_ON -async def async_setup(hass, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Expose fan control via statemachine and services.""" component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index ddf6a76d3c8..0482c31b929 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -28,7 +28,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Fan devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py index 15f8f4be45e..38cfb33b42d 100644 --- a/homeassistant/components/fan/device_trigger.py +++ b/homeassistant/components/fan/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Fan.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -16,12 +18,16 @@ TRIGGER_SCHEMA = toggle_entity.TRIGGER_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Fan devices.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 14f63a99e5d..fa1f18815f1 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -30,10 +30,10 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): """Implementation of a FAst.com sensor.""" _attr_name = "Fast.com Download" - _attr_unit_of_measurement = DATA_RATE_MEGABITS_PER_SECOND + _attr_native_unit_of_measurement = DATA_RATE_MEGABITS_PER_SECOND _attr_icon = ICON _attr_should_poll = False - _attr_state = None + _attr_native_value = None def __init__(self, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" @@ -52,14 +52,14 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): state = await self.async_get_last_state() if not state: return - self._attr_state = state.state + self._attr_native_value = state.state def update(self) -> None: """Get the latest data and update the states.""" data = self._speedtest_data.data # type: ignore[attr-defined] if data is None: return - self._attr_state = data["download"] + self._attr_native_value = data["download"] @callback def _schedule_immediate_update(self) -> None: diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 52e034c6265..74c826f47d6 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from homeassistant.loader import bind_hass DOMAIN = "ffmpeg" @@ -89,15 +90,26 @@ async def async_setup(hass, config): return True +@bind_hass async def async_get_image( hass: HomeAssistant, input_source: str, output_format: str = IMAGE_JPEG, extra_cmd: str | None = None, + width: int | None = None, + height: int | None = None, ) -> bytes | None: """Get an image from a frame of an RTSP stream.""" manager = hass.data[DATA_FFMPEG] ffmpeg = ImageFrame(manager.binary) + + if width and height and (extra_cmd is None or "-s" not in extra_cmd): + size_cmd = f"-s {width}x{height}" + if extra_cmd is None: + extra_cmd = size_cmd + else: + extra_cmd += " " + size_cmd + image = await asyncio.shield( ffmpeg.get_image(input_source, output_format=output_format, extra_cmd=extra_cmd) ) diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 4cd8b0d1453..323eae7c129 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -1,4 +1,5 @@ """Support for Cameras with FFmpeg as decoder.""" +from __future__ import annotations from haffmpeg.camera import CameraMjpeg from haffmpeg.tools import IMAGE_JPEG @@ -49,7 +50,9 @@ class FFmpegCamera(Camera): """Return the stream source.""" return self._input.split(" ")[-1] - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return await async_get_image( self.hass, diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 3161e173b2a..a4b4e744af7 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -85,12 +85,12 @@ class FibaroSensor(FibaroDevice, SensorEntity): self._unit = self.fibaro_device.properties.unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.current_value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 55ec455d8f1..0e61b580902 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -4,6 +4,8 @@ Support for Fido. Get data from 'Usage Summary' page: https://www.fido.ca/pages/#/my-account/wireless """ +from __future__ import annotations + from datetime import timedelta import logging @@ -11,7 +13,11 @@ from pyfido import FidoClient from pyfido.client import PyFidoError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_VARIABLES, CONF_NAME, @@ -33,33 +39,135 @@ DEFAULT_NAME = "Fido" REQUESTS_TIMEOUT = 15 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -SENSOR_TYPES = { - "fido_dollar": ["Fido dollar", PRICE, "mdi:cash-usd"], - "balance": ["Balance", PRICE, "mdi:cash-usd"], - "data_used": ["Data used", DATA_KILOBITS, "mdi:download"], - "data_limit": ["Data limit", DATA_KILOBITS, "mdi:download"], - "data_remaining": ["Data remaining", DATA_KILOBITS, "mdi:download"], - "text_used": ["Text used", MESSAGES, "mdi:message-text"], - "text_limit": ["Text limit", MESSAGES, "mdi:message-text"], - "text_remaining": ["Text remaining", MESSAGES, "mdi:message-text"], - "mms_used": ["MMS used", MESSAGES, "mdi:message-image"], - "mms_limit": ["MMS limit", MESSAGES, "mdi:message-image"], - "mms_remaining": ["MMS remaining", MESSAGES, "mdi:message-image"], - "text_int_used": ["International text used", MESSAGES, "mdi:message-alert"], - "text_int_limit": ["International text limit", MESSAGES, "mdi:message-alert"], - "text_int_remaining": ["International remaining", MESSAGES, "mdi:message-alert"], - "talk_used": ["Talk used", TIME_MINUTES, "mdi:cellphone"], - "talk_limit": ["Talk limit", TIME_MINUTES, "mdi:cellphone"], - "talk_remaining": ["Talk remaining", TIME_MINUTES, "mdi:cellphone"], - "other_talk_used": ["Other Talk used", TIME_MINUTES, "mdi:cellphone"], - "other_talk_limit": ["Other Talk limit", TIME_MINUTES, "mdi:cellphone"], - "other_talk_remaining": ["Other Talk remaining", TIME_MINUTES, "mdi:cellphone"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="fido_dollar", + name="Fido dollar", + native_unit_of_measurement=PRICE, + icon="mdi:cash-usd", + ), + SensorEntityDescription( + key="balance", + name="Balance", + native_unit_of_measurement=PRICE, + icon="mdi:cash-usd", + ), + SensorEntityDescription( + key="data_used", + name="Data used", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="data_limit", + name="Data limit", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="data_remaining", + name="Data remaining", + native_unit_of_measurement=DATA_KILOBITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="text_used", + name="Text used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="text_limit", + name="Text limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="text_remaining", + name="Text remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-text", + ), + SensorEntityDescription( + key="mms_used", + name="MMS used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="mms_limit", + name="MMS limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="mms_remaining", + name="MMS remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-image", + ), + SensorEntityDescription( + key="text_int_used", + name="International text used", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="text_int_limit", + name="International text limit", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="text_int_remaining", + name="International remaining", + native_unit_of_measurement=MESSAGES, + icon="mdi:message-alert", + ), + SensorEntityDescription( + key="talk_used", + name="Talk used", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="talk_limit", + name="Talk limit", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="talk_remaining", + name="Talk remaining", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_used", + name="Other Talk used", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_limit", + name="Other Talk limit", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), + SensorEntityDescription( + key="other_talk_remaining", + name="Other Talk remaining", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:cellphone", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -70,8 +178,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Fido sensor.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] httpsession = hass.helpers.aiohttp_client.async_get_clientsession() fido_data = FidoData(username, password, httpsession) @@ -79,49 +187,28 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if ret is False: return - name = config.get(CONF_NAME) + name = config[CONF_NAME] + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + FidoSensor(fido_data, name, number, description) + for number in fido_data.client.get_phone_numbers() + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - sensors = [] - for number in fido_data.client.get_phone_numbers(): - for variable in config[CONF_MONITORED_VARIABLES]: - sensors.append(FidoSensor(fido_data, variable, name, number)) - - async_add_entities(sensors, True) + async_add_entities(entities, True) class FidoSensor(SensorEntity): """Implementation of a Fido sensor.""" - def __init__(self, fido_data, sensor_type, name, number): + def __init__(self, fido_data, name, number, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self._number = number - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description self.fido_data = fido_data - self._state = None + self._number = number - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._number} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + self._attr_name = f"{name} {number} {description.name}" @property def extra_state_attributes(self): @@ -131,13 +218,15 @@ class FidoSensor(SensorEntity): async def async_update(self): """Get the latest data from Fido and update the state.""" await self.fido_data.async_update() - if self.type == "balance": - if self.fido_data.data.get(self.type) is not None: - self._state = round(self.fido_data.data[self.type], 2) + sensor_type = self.entity_description.key + if sensor_type == "balance": + if self.fido_data.data.get(sensor_type) is not None: + self._attr_native_value = round(self.fido_data.data[sensor_type], 2) else: - if self.fido_data.data.get(self._number, {}).get(self.type) is not None: - self._state = self.fido_data.data[self._number][self.type] - self._state = round(self._state, 2) + if self.fido_data.data.get(self._number, {}).get(sensor_type) is not None: + self._attr_native_value = round( + self.fido_data.data[self._number][sensor_type], 2 + ) class FidoData: diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index 5d8a9475235..73b262c9090 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -62,7 +62,7 @@ class FileSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -72,7 +72,7 @@ class FileSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 856b29364ae..dc44d3d8255 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -63,7 +63,7 @@ class Filesize(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the size of the file in MB.""" decimals = 2 state_mb = round(self._size / 1e6, decimals) @@ -84,6 +84,6 @@ class Filesize(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index e303dc1cf96..f9705887549 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -209,7 +209,7 @@ class SensorFilter(SensorEntity): self.async_write_ha_state() return - if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._state = new_state.state self.async_write_ha_state() return @@ -332,7 +332,7 @@ class SensorFilter(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -342,7 +342,7 @@ class SensorFilter(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement of the device.""" return self._unit_of_measurement @@ -405,7 +405,7 @@ class Filter: :param entity: used for debugging only """ if isinstance(window_size, int): - self.states = deque(maxlen=window_size) + self.states: deque = deque(maxlen=window_size) self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS else: self.states = deque(maxlen=0) @@ -476,7 +476,7 @@ class RangeFilter(Filter, SensorEntity): super().__init__(FILTER_NAME_RANGE, precision=precision, entity=entity) self._lower_bound = lower_bound self._upper_bound = upper_bound - self._stats_internal = Counter() + self._stats_internal: Counter = Counter() def _filter_state(self, new_state): """Implement the range filter.""" @@ -522,7 +522,7 @@ class OutlierFilter(Filter, SensorEntity): """ super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) self._radius = radius - self._stats_internal = Counter() + self._stats_internal: Counter = Counter() self._store_raw = True def _filter_state(self, new_state): diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 3487444c735..d584bbed4bb 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -1,8 +1,10 @@ """Read the balance of your bank accounts via FinTS.""" +from __future__ import annotations from collections import namedtuple from datetime import timedelta import logging +from typing import Any from fints.client import FinTS3PinTanClient from fints.dialog import FinTSDialogError @@ -164,46 +166,23 @@ class FinTsAccount(SensorEntity): """Initialize a FinTs balance account.""" self._client = client self._account = account - self._name = name - self._balance: float = None - self._currency: str = None + self._attr_name = name + self._attr_icon = ICON + self._attr_extra_state_attributes = { + ATTR_ACCOUNT: self._account.iban, + ATTR_ACCOUNT_TYPE: "balance", + } + if self._client.name: + self._attr_extra_state_attributes[ATTR_BANK] = self._client.name def update(self) -> None: """Get the current balance and currency for the account.""" bank = self._client.client balance = bank.get_balance(self._account) - self._balance = balance.amount.amount - self._currency = balance.amount.currency + self._attr_native_value = balance.amount.amount + self._attr_native_unit_of_measurement = balance.amount.currency _LOGGER.debug("updated balance of account %s", self.name) - @property - def name(self) -> str: - """Friendly name of the sensor.""" - return self._name - - @property - def state(self) -> float: - """Return the balance of the account as state.""" - return self._balance - - @property - def unit_of_measurement(self) -> str: - """Use the currency as unit of measurement.""" - return self._currency - - @property - def extra_state_attributes(self) -> dict: - """Additional attributes of the sensor.""" - attributes = {ATTR_ACCOUNT: self._account.iban, ATTR_ACCOUNT_TYPE: "balance"} - if self._client.name: - attributes[ATTR_BANK] = self._client.name - return attributes - - @property - def icon(self) -> str: - """Set the icon for the sensor.""" - return ICON - class FinTsHoldingsAccount(SensorEntity): """Sensor for a FinTS holdings account. @@ -215,26 +194,17 @@ class FinTsHoldingsAccount(SensorEntity): def __init__(self, client: FinTsClient, account, name: str) -> None: """Initialize a FinTs holdings account.""" self._client = client - self._name = name + self._attr_name = name self._account = account - self._holdings = [] - self._total: float = None + self._holdings: list[Any] = [] + self._attr_icon = ICON + self._attr_native_unit_of_measurement = "EUR" def update(self) -> None: """Get the current holdings for the account.""" bank = self._client.client self._holdings = bank.get_holdings(self._account) - self._total = sum(h.total_value for h in self._holdings) - - @property - def state(self) -> float: - """Return total market value as state.""" - return self._total - - @property - def icon(self) -> str: - """Set the icon for the sensor.""" - return ICON + self._attr_native_value = sum(h.total_value for h in self._holdings) @property def extra_state_attributes(self) -> dict: @@ -257,18 +227,3 @@ class FinTsHoldingsAccount(SensorEntity): attributes[price_name] = holding.market_value return attributes - - @property - def name(self) -> str: - """Friendly name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self) -> str: - """Get the unit of measurement. - - Hardcoded to EUR, as the library does not provide the currency for the - holdings. And as FinTS is only used in Germany, most accounts will be - in EUR anyways. - """ - return "EUR" diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 58b3239331c..ec446621212 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -49,7 +49,7 @@ class IncidentsSensor(RestoreEntity, SensorEntity): return "mdi:fire-truck" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/fireservicerota/translations/es-419.json b/homeassistant/components/fireservicerota/translations/es-419.json new file mode 100644 index 00000000000..cf14204ec0c --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/es-419.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "reauth": { + "description": "Los tokens de autenticaci\u00f3n dejaron de ser v\u00e1lidos, inicie sesi\u00f3n para volver a crearlos." + }, + "user": { + "data": { + "url": "Sitio web" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py index 24b6420e8a5..d98866f900b 100644 --- a/homeassistant/components/firmata/__init__.py +++ b/homeassistant/components/firmata/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .board import FirmataBoard from .const import ( @@ -122,7 +123,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Firmata domain.""" # Delete specific entries that no longer exist in the config if hass.config_entries.async_entries(DOMAIN): diff --git a/homeassistant/components/firmata/sensor.py b/homeassistant/components/firmata/sensor.py index fedac6f76d9..b46e96f3c25 100644 --- a/homeassistant/components/firmata/sensor.py +++ b/homeassistant/components/firmata/sensor.py @@ -54,6 +54,6 @@ class FirmataSensor(FirmataPinEntity, SensorEntity): await self._api.stop_pin() @property - def state(self) -> int: + def native_value(self) -> int: """Return sensor state.""" return self._api.state diff --git a/homeassistant/components/firmata/translations/hu.json b/homeassistant/components/firmata/translations/hu.json index 563ede56155..8224d177a9f 100644 --- a/homeassistant/components/firmata/translations/hu.json +++ b/homeassistant/components/firmata/translations/hu.json @@ -2,6 +2,10 @@ "config": { "abort": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "one": "\u00dcres", + "other": "\u00dcres" } } } \ No newline at end of file diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 9f99b3d0bb0..0bd4ed36199 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -374,12 +374,12 @@ class FitbitSensor(SensorEntity): return self._name @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 9214dd6907e..3108f7d3272 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -64,12 +64,12 @@ class ExchangeRateSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._target @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py new file mode 100644 index 00000000000..9d635e3bf7f --- /dev/null +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -0,0 +1,143 @@ +"""The Fjäråskupan integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Callable + +from bleak import BleakScanner +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from fjaraskupan import Device, State, device_filter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DISPATCH_DETECTION, DOMAIN + +PLATFORMS = ["binary_sensor", "fan", "light", "sensor"] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeviceState: + """Store state of a device.""" + + device: Device + coordinator: DataUpdateCoordinator[State] + device_info: DeviceInfo + + +@dataclass +class EntryState: + """Store state of config entry.""" + + scanner: BleakScanner + devices: dict[str, DeviceState] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Fjäråskupan from a config entry.""" + + scanner = BleakScanner() + + state = EntryState(scanner, {}) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = state + + async def detection_callback( + ble_device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + if not device_filter(ble_device, advertisement_data): + return + + _LOGGER.debug( + "Detection: %s %s - %s", ble_device.name, ble_device, advertisement_data + ) + + data = state.devices.get(ble_device.address) + + if data: + data.device.detection_callback(ble_device, advertisement_data) + data.coordinator.async_set_updated_data(data.device.state) + else: + + device = Device(ble_device) + device.detection_callback(ble_device, advertisement_data) + + async def async_update_data(): + """Handle an explicit update request.""" + await device.update() + return device.state + + coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator( + hass, + logger=_LOGGER, + name="Fjaraskupan Updater", + update_interval=timedelta(seconds=120), + update_method=async_update_data, + ) + coordinator.async_set_updated_data(device.state) + + device_info: DeviceInfo = { + "identifiers": {(DOMAIN, ble_device.address)}, + "manufacturer": "Fjäråskupan", + "name": "Fjäråskupan", + } + device_state = DeviceState(device, coordinator, device_info) + state.devices[ble_device.address] = device_state + async_dispatcher_send( + hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", device_state + ) + + scanner.register_detection_callback(detection_callback) + await scanner.start() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +@callback +def async_setup_entry_platform( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + constructor: Callable[[DeviceState], list[Entity]], +) -> None: + """Set up a platform with added entities.""" + + entry_state: EntryState = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + entity + for device_state in entry_state.devices.values() + for entity in constructor(device_state) + ) + + @callback + def _detection(device_state: DeviceState) -> None: + async_add_entities(constructor(device_state)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", _detection + ) + ) + + +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: + entry_state: EntryState = hass.data[DOMAIN].pop(entry.entry_id) + await entry_state.scanner.stop() + + return unload_ok diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py new file mode 100644 index 00000000000..2484a0d9bc2 --- /dev/null +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -0,0 +1,95 @@ +"""Support for sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from fjaraskupan import Device, State + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_PROBLEM, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +@dataclass +class EntityDescription(BinarySensorEntityDescription): + """Entity description.""" + + is_on: Callable = lambda _: False + + +SENSORS = ( + EntityDescription( + key="grease-filter", + name="Grease Filter", + device_class=DEVICE_CLASS_PROBLEM, + is_on=lambda state: state.grease_filter_full, + ), + EntityDescription( + key="carbon-filter", + name="Carbon Filter", + device_class=DEVICE_CLASS_PROBLEM, + is_on=lambda state: state.carbon_filter_full, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + BinarySensor( + device_state.coordinator, + device_state.device, + device_state.device_info, + entity_description, + ) + for entity_description in SENSORS + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class BinarySensor(CoordinatorEntity[State], BinarySensorEntity): + """Grease filter sensor.""" + + entity_description: EntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + entity_description: EntityDescription, + ) -> None: + """Init sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + + self._attr_unique_id = f"{device.address}-{entity_description.key}" + self._attr_device_info = device_info + self._attr_name = f"{device_info['name']} {entity_description.name}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if data := self.coordinator.data: + return self.entity_description.is_on(data) + return None diff --git a/homeassistant/components/fjaraskupan/config_flow.py b/homeassistant/components/fjaraskupan/config_flow.py new file mode 100644 index 00000000000..9b82ae1199b --- /dev/null +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -0,0 +1,38 @@ +"""Config flow for Fjäråskupan integration.""" +from __future__ import annotations + +import asyncio + +import async_timeout +from bleak import BleakScanner +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from fjaraskupan import device_filter + +from homeassistant.helpers.config_entry_flow import register_discovery_flow + +from .const import DOMAIN + +CONST_WAIT_TIME = 5.0 + + +async def _async_has_devices(hass) -> bool: + """Return if there are devices that can be discovered.""" + + event = asyncio.Event() + + def detection(device: BLEDevice, advertisement_data: AdvertisementData): + if device_filter(device, advertisement_data): + event.set() + + async with BleakScanner(detection_callback=detection): + try: + async with async_timeout.timeout(CONST_WAIT_TIME): + await event.wait() + except asyncio.TimeoutError: + return False + + return True + + +register_discovery_flow(DOMAIN, "Fjäråskupan", _async_has_devices) diff --git a/homeassistant/components/fjaraskupan/const.py b/homeassistant/components/fjaraskupan/const.py new file mode 100644 index 00000000000..957ac518293 --- /dev/null +++ b/homeassistant/components/fjaraskupan/const.py @@ -0,0 +1,5 @@ +"""Constants for the Fjäråskupan integration.""" + +DOMAIN = "fjaraskupan" + +DISPATCH_DETECTION = f"{DOMAIN}.detection" diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py new file mode 100644 index 00000000000..4a81e70b848 --- /dev/null +++ b/homeassistant/components/fjaraskupan/fan.py @@ -0,0 +1,192 @@ +"""Support for Fjäråskupan fans.""" +from __future__ import annotations + +from fjaraskupan import ( + COMMAND_AFTERCOOKINGTIMERAUTO, + COMMAND_AFTERCOOKINGTIMERMANUAL, + COMMAND_AFTERCOOKINGTIMEROFF, + COMMAND_STOP_FAN, + Device, + State, +) + +from homeassistant.components.fan import ( + SUPPORT_PRESET_MODE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from . import DeviceState, async_setup_entry_platform + +ORDERED_NAMED_FAN_SPEEDS = ["1", "2", "3", "4", "5", "6", "7", "8"] + +PRESET_MODE_NORMAL = "normal" +PRESET_MODE_AFTER_COOKING_MANUAL = "after_cooking_manual" +PRESET_MODE_AFTER_COOKING_AUTO = "after_cooking_auto" +PRESET_MODES = [ + PRESET_MODE_NORMAL, + PRESET_MODE_AFTER_COOKING_AUTO, + PRESET_MODE_AFTER_COOKING_MANUAL, +] + +PRESET_TO_COMMAND = { + PRESET_MODE_AFTER_COOKING_MANUAL: COMMAND_AFTERCOOKINGTIMERMANUAL, + PRESET_MODE_AFTER_COOKING_AUTO: COMMAND_AFTERCOOKINGTIMERAUTO, + PRESET_MODE_NORMAL: COMMAND_AFTERCOOKINGTIMEROFF, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState): + return [ + Fan(device_state.coordinator, device_state.device, device_state.device_info) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class Fan(CoordinatorEntity[State], FanEntity): + """Fan entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init fan entity.""" + super().__init__(coordinator) + self._device = device + self._default_on_speed = 25 + self._attr_name = device_info["name"] + self._attr_unique_id = device.address + self._attr_device_info = device_info + self._percentage = 0 + self._preset_mode = PRESET_MODE_NORMAL + self._update_from_device_data(coordinator.data) + + async def async_set_percentage(self, percentage: int) -> None: + """Set speed.""" + new_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + await self._device.send_fan_speed(int(new_speed)) + self.coordinator.async_set_updated_data(self._device.state) + + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the fan.""" + + if preset_mode is None: + preset_mode = self._preset_mode + + if percentage is None: + percentage = self._default_on_speed + + new_speed = percentage_to_ordered_list_item( + ORDERED_NAMED_FAN_SPEEDS, percentage + ) + + async with self._device: + if preset_mode != self._preset_mode: + await self._device.send_command(PRESET_TO_COMMAND[preset_mode]) + + if preset_mode == PRESET_MODE_NORMAL: + await self._device.send_fan_speed(int(new_speed)) + elif preset_mode == PRESET_MODE_AFTER_COOKING_MANUAL: + await self._device.send_after_cooking(int(new_speed)) + elif preset_mode == PRESET_MODE_AFTER_COOKING_AUTO: + await self._device.send_after_cooking(0) + + self.coordinator.async_set_updated_data(self._device.state) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self._device.send_command(PRESET_TO_COMMAND[preset_mode]) + self.coordinator.async_set_updated_data(self._device.state) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + await self._device.send_command(COMMAND_STOP_FAN) + self.coordinator.async_set_updated_data(self._device.state) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) + + @property + def percentage(self) -> int | None: + """Return the current speed.""" + return self._percentage + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE + + @property + def is_on(self) -> bool: + """Return true if fan is on.""" + return self._percentage != 0 + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes.""" + return PRESET_MODES + + def _update_from_device_data(self, data: State | None) -> None: + """Handle data update.""" + if not data: + self._percentage = 0 + return + + if data.fan_speed: + self._percentage = ordered_list_item_to_percentage( + ORDERED_NAMED_FAN_SPEEDS, str(data.fan_speed) + ) + else: + self._percentage = 0 + + if data.after_cooking_on: + if data.after_cooking_fan_speed: + self._preset_mode = PRESET_MODE_AFTER_COOKING_MANUAL + else: + self._preset_mode = PRESET_MODE_AFTER_COOKING_AUTO + else: + self._preset_mode = PRESET_MODE_NORMAL + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + + self._update_from_device_data(self.coordinator.data) + self.async_write_ha_state() diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py new file mode 100644 index 00000000000..8c44460a099 --- /dev/null +++ b/homeassistant/components/fjaraskupan/light.py @@ -0,0 +1,85 @@ +"""Support for lights.""" +from __future__ import annotations + +from fjaraskupan import COMMAND_LIGHT_ON_OFF, Device, State + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + COLOR_MODE_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up tuya sensors dynamically through tuya discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + Light( + device_state.coordinator, device_state.device, device_state.device_info + ) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class Light(CoordinatorEntity[State], LightEntity): + """Light device.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init light entity.""" + super().__init__(coordinator) + self._device = device + self._attr_color_mode = COLOR_MODE_BRIGHTNESS + self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + self._attr_unique_id = device.address + self._attr_device_info = device_info + self._attr_name = device_info["name"] + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + await self._device.send_dim(int(kwargs[ATTR_BRIGHTNESS] * (100.0 / 255.0))) + else: + if not self.is_on: + await self._device.send_command(COMMAND_LIGHT_ON_OFF) + self.coordinator.async_set_updated_data(self._device.state) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if self.is_on: + await self._device.send_command(COMMAND_LIGHT_ON_OFF) + self.coordinator.async_set_updated_data(self._device.state) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if data := self.coordinator.data: + return data.light_on + return False + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + if data := self.coordinator.data: + return int(data.dim_level * (255.0 / 100.0)) + return None diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json new file mode 100644 index 00000000000..68158776afe --- /dev/null +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "fjaraskupan", + "name": "Fj\u00e4r\u00e5skupan", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fjaraskupan", + "requirements": [ + "fjaraskupan==1.0.0" + ], + "codeowners": [ + "@elupus" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py new file mode 100644 index 00000000000..4252828c633 --- /dev/null +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -0,0 +1,66 @@ +"""Support for sensors.""" +from __future__ import annotations + +from fjaraskupan import Device, State + +from homeassistant.components.sensor import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeviceState, async_setup_entry_platform + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors dynamically through discovery.""" + + def _constructor(device_state: DeviceState) -> list[Entity]: + return [ + RssiSensor( + device_state.coordinator, device_state.device, device_state.device_info + ) + ] + + async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) + + +class RssiSensor(CoordinatorEntity[State], SensorEntity): + """Sensor device.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[State], + device: Device, + device_info: DeviceInfo, + ) -> None: + """Init sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{device.address}-signal-strength" + self._attr_device_info = device_info + self._attr_name = f"{device_info['name']} Signal Strength" + self._attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + self._attr_entity_registry_enabled_default = False + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + if data := self.coordinator.data: + return data.rssi + return None diff --git a/homeassistant/components/fjaraskupan/strings.json b/homeassistant/components/fjaraskupan/strings.json new file mode 100644 index 00000000000..c72fc777772 --- /dev/null +++ b/homeassistant/components/fjaraskupan/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Do you want to set up Fjäråskupan?" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/ca.json b/homeassistant/components/fjaraskupan/translations/ca.json new file mode 100644 index 00000000000..56172862caa --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/ca.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius a la xarxa", + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "step": { + "confirm": { + "description": "Vols configurar Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/de.json b/homeassistant/components/fjaraskupan/translations/de.json new file mode 100644 index 00000000000..d1150e177c7 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/de.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "step": { + "confirm": { + "description": "M\u00f6chtest du Fj\u00e4r\u00e5skupan einrichten?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/en.json b/homeassistant/components/fjaraskupan/translations/en.json new file mode 100644 index 00000000000..c0616b6b9e6 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "confirm": { + "description": "Do you want to set up Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/et.json b/homeassistant/components/fjaraskupan/translations/et.json new file mode 100644 index 00000000000..57fe9c81c8f --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "V\u00f5rgust ei leitud \u00fchtegi seadet", + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + }, + "step": { + "confirm": { + "description": "Kas soovid seadistada Fj\u00e4r\u00e5skupani?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/he.json b/homeassistant/components/fjaraskupan/translations/he.json new file mode 100644 index 00000000000..380dbc5d7fc --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/nl.json b/homeassistant/components/fjaraskupan/translations/nl.json new file mode 100644 index 00000000000..498ef7af1be --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "step": { + "confirm": { + "description": "Wil je Fj\u00e4r\u00e5skupan opzetten?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/no.json b/homeassistant/components/fjaraskupan/translations/no.json new file mode 100644 index 00000000000..b05779cbe06 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "step": { + "confirm": { + "description": "Vil du sette opp Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/pl.json b/homeassistant/components/fjaraskupan/translations/pl.json new file mode 100644 index 00000000000..65fcb66af6d --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/pl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/ru.json b/homeassistant/components/fjaraskupan/translations/ru.json new file mode 100644 index 00000000000..5d165713eb1 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/ru.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "step": { + "confirm": { + "description": "\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 Fj\u00e4r\u00e5skupan?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/translations/zh-Hant.json b/homeassistant/components/fjaraskupan/translations/zh-Hant.json new file mode 100644 index 00000000000..3312cea3576 --- /dev/null +++ b/homeassistant/components/fjaraskupan/translations/zh-Hant.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Fj\u00e4r\u00e5skupan\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index cf4662b9866..ce3e5a68e1e 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from pyflexit.pyflexit import pyflexit import voluptuous as vol from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity @@ -12,7 +11,15 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN +from homeassistant.components.modbus import get_hub +from homeassistant.components.modbus.const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CALL_TYPE_WRITE_REGISTER, + CONF_HUB, + DEFAULT_HUB, +) +from homeassistant.components.modbus.modbus import ModbusHub from homeassistant.const import ( ATTR_TEMPERATURE, CONF_NAME, @@ -20,7 +27,9 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -35,18 +44,25 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities, + discovery_info: DiscoveryInfoType = None, +): """Set up the Flexit Platform.""" modbus_slave = config.get(CONF_SLAVE) name = config.get(CONF_NAME) - hub = hass.data[MODBUS_DOMAIN][config.get(CONF_HUB)] - add_entities([Flexit(hub, modbus_slave, name)], True) + hub = get_hub(hass, config[CONF_HUB]) + async_add_entities([Flexit(hub, modbus_slave, name)], True) class Flexit(ClimateEntity): """Representation of a Flexit AC unit.""" - def __init__(self, hub, modbus_slave, name): + def __init__( + self, hub: ModbusHub, modbus_slave: int | None, name: str | None + ) -> None: """Initialize the unit.""" self._hub = hub self._name = name @@ -64,34 +80,65 @@ class Flexit(ClimateEntity): self._heating = None self._cooling = None self._alarm = False - self.unit = pyflexit(hub, modbus_slave) + self._outdoor_air_temp = None @property def supported_features(self): """Return the list of supported features.""" return SUPPORT_FLAGS - def update(self): + async def async_update(self): """Update unit attributes.""" - if not self.unit.update(): - _LOGGER.warning("Modbus read failed") + self._target_temperature = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_HOLDING, 8 + ) + self._current_temperature = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_INPUT, 9 + ) + res = await self._async_read_int16_from_register(CALL_TYPE_REGISTER_HOLDING, 17) + if res < len(self._fan_modes): + self._current_fan_mode = res + self._filter_hours = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 8 + ) + # # Mechanical heat recovery, 0-100% + self._heat_recovery = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 14 + ) + # # Heater active 0-100% + self._heating = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 15 + ) + # # Cooling active 0-100% + self._cooling = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 13 + ) + # # Filter alarm 0/1 + self._filter_alarm = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 27 + ) + # # Heater enabled or not. Does not mean it's necessarily heating + self._heater_enabled = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 28 + ) + self._outdoor_air_temp = await self._async_read_temp_from_register( + CALL_TYPE_REGISTER_INPUT, 11 + ) - self._target_temperature = self.unit.get_target_temp - self._current_temperature = self.unit.get_temp - self._current_fan_mode = self._fan_modes[self.unit.get_fan_speed] - self._filter_hours = self.unit.get_filter_hours - # Mechanical heat recovery, 0-100% - self._heat_recovery = self.unit.get_heat_recovery - # Heater active 0-100% - self._heating = self.unit.get_heating - # Cooling active 0-100% - self._cooling = self.unit.get_cooling - # Filter alarm 0/1 - self._filter_alarm = self.unit.get_filter_alarm - # Heater enabled or not. Does not mean it's necessarily heating - self._heater_enabled = self.unit.get_heater_enabled - # Current operation mode - self._current_operation = self.unit.get_operation + actual_air_speed = await self._async_read_int16_from_register( + CALL_TYPE_REGISTER_INPUT, 48 + ) + + if self._heating: + self._current_operation = "Heating" + elif self._cooling: + self._current_operation = "Cooling" + elif self._heat_recovery: + self._current_operation = "Recovering" + elif actual_air_speed: + self._current_operation = "Fan Only" + else: + self._current_operation = "Off" @property def extra_state_attributes(self): @@ -103,6 +150,7 @@ class Flexit(ClimateEntity): "heating": self._heating, "heater_enabled": self._heater_enabled, "cooling": self._cooling, + "outdoor_air_temp": self._outdoor_air_temp, } @property @@ -153,12 +201,53 @@ class Flexit(ClimateEntity): """Return the list of available fan modes.""" return self._fan_modes - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if kwargs.get(ATTR_TEMPERATURE) is not None: - self._target_temperature = kwargs.get(ATTR_TEMPERATURE) - self.unit.set_temp(self._target_temperature) + target_temperature = kwargs.get(ATTR_TEMPERATURE) + else: + _LOGGER.error("Received invalid temperature") + return - def set_fan_mode(self, fan_mode): + if await self._async_write_int16_to_register(8, target_temperature * 10): + self._target_temperature = target_temperature + else: + _LOGGER.error("Modbus error setting target temperature to Flexit") + + async def async_set_fan_mode(self, fan_mode): """Set new fan mode.""" - self.unit.set_fan_speed(self._fan_modes.index(fan_mode)) + if await self._async_write_int16_to_register( + 17, self.fan_modes.index(fan_mode) + ): + self._current_fan_mode = self.fan_modes.index(fan_mode) + else: + _LOGGER.error("Modbus error setting fan mode to Flexit") + + # Based on _async_read_register in ModbusThermostat class + async def _async_read_int16_from_register(self, register_type, register) -> int: + """Read register using the Modbus hub slave.""" + result = await self._hub.async_pymodbus_call( + self._slave, register, 1, register_type + ) + if result is None: + _LOGGER.error("Error reading value from Flexit modbus adapter") + return -1 + + return int(result.registers[0]) + + async def _async_read_temp_from_register(self, register_type, register) -> float: + result = float( + await self._async_read_int16_from_register(register_type, register) + ) + if result == -1: + return -1 + return result / 10.0 + + async def _async_write_int16_to_register(self, register, value) -> bool: + value = int(value) + result = await self._hub.async_pymodbus_call( + self._slave, register, value, CALL_TYPE_WRITE_REGISTER + ) + if result == -1: + return False + return True diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json index 96ed5b55904..d9f84d5ab81 100644 --- a/homeassistant/components/flexit/manifest.json +++ b/homeassistant/components/flexit/manifest.json @@ -2,7 +2,6 @@ "domain": "flexit", "name": "Flexit", "documentation": "https://www.home-assistant.io/integrations/flexit", - "requirements": ["pyflexit==0.3"], "dependencies": ["modbus"], "codeowners": [], "iot_class": "local_polling" diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index ab628e205c7..938507e4b0c 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -36,7 +36,7 @@ async def async_setup_entry( class FlickPricingSensor(SensorEntity): """Entity object for Flick Electric sensor.""" - _attr_unit_of_measurement = UNIT_NAME + _attr_native_unit_of_measurement = UNIT_NAME def __init__(self, api: FlickAPI) -> None: """Entity object for Flick Electric sensor.""" @@ -53,7 +53,7 @@ class FlickPricingSensor(SensorEntity): return FRIENDLY_NAME @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._price.price diff --git a/homeassistant/components/flick_electric/translations/es-419.json b/homeassistant/components/flick_electric/translations/es-419.json new file mode 100644 index 00000000000..59ecddf99c3 --- /dev/null +++ b/homeassistant/components/flick_electric/translations/es-419.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "client_id": "Id. de cliente (opcional)", + "client_secret": "Secreto de cliente (opcional)" + }, + "title": "Credenciales de acceso a Flick" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/hu.json b/homeassistant/components/flick_electric/translations/hu.json index f7ed726e433..90ea92089e1 100644 --- a/homeassistant/components/flick_electric/translations/hu.json +++ b/homeassistant/components/flick_electric/translations/hu.json @@ -11,6 +11,8 @@ "step": { "user": { "data": { + "client_id": "Kliens ID (opcion\u00e1lis)", + "client_secret": "Kliens jelsz\u00f3 (nem k\u00f6telez\u0151)", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 05bbd0d5449..66ea93484f7 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -35,7 +35,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) @@ -54,8 +54,6 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator): password = entry.data[CONF_PASSWORD] self.flipr_id = entry.data[CONF_FLIPR_ID] - _LOGGER.debug("Config entry values : %s, %s", username, self.flipr_id) - # Establishes the connection. self.client = FliprAPIRestClient(username, password) self.entry = entry diff --git a/homeassistant/components/flipr/config_flow.py b/homeassistant/components/flipr/config_flow.py index b503281fed4..b1e4f31d044 100644 --- a/homeassistant/components/flipr/config_flow.py +++ b/homeassistant/components/flipr/config_flow.py @@ -45,7 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" _LOGGER.exception(exception) - if not errors and len(flipr_ids) == 0: + if not errors and not flipr_ids: # No flipr_id found. Tell the user with an error message. errors["base"] = "no_flipr_id_found" @@ -85,9 +85,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _authenticate_and_search_flipr(self) -> list[str]: """Validate the username and password provided and searches for a flipr id.""" - client = await self.hass.async_add_executor_job( - FliprAPIRestClient, self._username, self._password - ) + # Instantiates the flipr API that does not require async since it is has no network access. + client = FliprAPIRestClient(self._username, self._password) flipr_ids = await self.hass.async_add_executor_job(client.search_flipr_ids) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 427a668a72b..f9fd4e9633e 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -1,20 +1,21 @@ """Sensor platform for the Flipr's pool_sensor.""" from datetime import datetime +from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + ELECTRIC_POTENTIAL_MILLIVOLT, TEMP_CELSIUS, ) -from homeassistant.helpers.entity import Entity from . import FliprEntity from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN SENSORS = { "chlorine": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Chlorine", "device_class": None, @@ -33,7 +34,7 @@ SENSORS = { "device_class": DEVICE_CLASS_TIMESTAMP, }, "red_ox": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Red OX", "device_class": None, @@ -53,7 +54,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(sensors_list, True) -class FliprSensor(FliprEntity, Entity): +class FliprSensor(FliprEntity, SensorEntity): """Sensor representing FliprSensor data.""" @property @@ -62,7 +63,7 @@ class FliprSensor(FliprEntity, Entity): return f"Flipr {self.flipr_id} {SENSORS[self.info_type]['name']}" @property - def state(self): + def native_value(self): """State of the sensor.""" state = self.coordinator.data[self.info_type] if isinstance(state, datetime): @@ -80,7 +81,7 @@ class FliprSensor(FliprEntity, Entity): return SENSORS[self.info_type]["icon"] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement.""" return SENSORS[self.info_type]["unit"] diff --git a/homeassistant/components/flipr/translations/es.json b/homeassistant/components/flipr/translations/es.json new file mode 100644 index 00000000000..766f83856ec --- /dev/null +++ b/homeassistant/components/flipr/translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "error": { + "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" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID de Flipr" + }, + "description": "Elija su ID de Flipr en la lista", + "title": "Elige tu Flipr" + }, + "user": { + "data": { + "email": "Correo-e", + "password": "Clave" + }, + "description": "Con\u00e9ctese usando su cuenta Flipr.", + "title": "Conectarse a Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/hu.json b/homeassistant/components/flipr/translations/hu.json new file mode 100644 index 00000000000..4daf0446abc --- /dev/null +++ b/homeassistant/components/flipr/translations/hu.json @@ -0,0 +1,30 @@ +{ + "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", + "no_flipr_id_found": "A fi\u00f3kj\u00e1hoz jelenleg nem tartozik Flipr-azonos\u00edt\u00f3. El\u0151sz\u00f6r ellen\u0151riznie kell, hogy m\u0171k\u00f6dik-e a Flipr mobilalkalmaz\u00e1s\u00e1val.", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr azonos\u00edt\u00f3" + }, + "description": "V\u00e1lassza ki a Flipr azonos\u00edt\u00f3j\u00e1t a list\u00e1b\u00f3l", + "title": "V\u00e1lassza ki a Flipr-t" + }, + "user": { + "data": { + "email": "Email", + "password": "Jelsz\u00f3" + }, + "description": "Csatlakozzon a Flipr-fi\u00f3kj\u00e1val.", + "title": "Csatlakoz\u00e1s a Flipr-hez" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/no.json b/homeassistant/components/flipr/translations/no.json new file mode 100644 index 00000000000..550b0bae058 --- /dev/null +++ b/homeassistant/components/flipr/translations/no.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "no_flipr_id_found": "Ingen flipr -ID er knyttet til kontoen din forel\u00f8pig. Du b\u00f8r bekrefte at den fungerer med Flipr -mobilappen f\u00f8rst.", + "unknown": "Uventet feil" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Velg din Flipr -ID i listen", + "title": "Velg din Flipr" + }, + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "description": "Koble til ved hjelp av Flipr-kontoen din.", + "title": "Koble til Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/zh-Hans.json b/homeassistant/components/flipr/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/flipr/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index 0504d451e14..b64ed9ee3e4 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -61,7 +61,7 @@ class FloDailyUsageSensor(FloEntity, SensorEntity): """Monitors the daily water usage.""" _attr_icon = WATER_ICON - _attr_unit_of_measurement = VOLUME_GALLONS + _attr_native_unit_of_measurement = VOLUME_GALLONS def __init__(self, device): """Initialize the daily water usage sensor.""" @@ -69,7 +69,7 @@ class FloDailyUsageSensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current daily usage.""" if self._device.consumption_today is None: return None @@ -85,7 +85,7 @@ class FloSystemModeSensor(FloEntity, SensorEntity): self._state: str = None @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the current system mode.""" if not self._device.current_system_mode: return None @@ -96,7 +96,7 @@ class FloCurrentFlowRateSensor(FloEntity, SensorEntity): """Monitors the current water flow rate.""" _attr_icon = GAUGE_ICON - _attr_unit_of_measurement = "gpm" + _attr_native_unit_of_measurement = "gpm" def __init__(self, device): """Initialize the flow rate sensor.""" @@ -104,7 +104,7 @@ class FloCurrentFlowRateSensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current flow rate.""" if self._device.current_flow_rate is None: return None @@ -115,7 +115,7 @@ class FloTemperatureSensor(FloEntity, SensorEntity): """Monitors the temperature.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_FAHRENHEIT + _attr_native_unit_of_measurement = TEMP_FAHRENHEIT def __init__(self, name, device): """Initialize the temperature sensor.""" @@ -123,7 +123,7 @@ class FloTemperatureSensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current temperature.""" if self._device.temperature is None: return None @@ -134,7 +134,7 @@ class FloHumiditySensor(FloEntity, SensorEntity): """Monitors the humidity.""" _attr_device_class = DEVICE_CLASS_HUMIDITY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device): """Initialize the humidity sensor.""" @@ -142,7 +142,7 @@ class FloHumiditySensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current humidity.""" if self._device.humidity is None: return None @@ -153,7 +153,7 @@ class FloPressureSensor(FloEntity, SensorEntity): """Monitors the water pressure.""" _attr_device_class = DEVICE_CLASS_PRESSURE - _attr_unit_of_measurement = PRESSURE_PSI + _attr_native_unit_of_measurement = PRESSURE_PSI def __init__(self, device): """Initialize the pressure sensor.""" @@ -161,7 +161,7 @@ class FloPressureSensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current water pressure.""" if self._device.current_psi is None: return None @@ -172,7 +172,7 @@ class FloBatterySensor(FloEntity, SensorEntity): """Monitors the battery level for battery-powered leak detectors.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device): """Initialize the battery sensor.""" @@ -180,6 +180,6 @@ class FloBatterySensor(FloEntity, SensorEntity): self._state: float = None @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the current battery level.""" return self._device.battery_level diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index d890443d238..ee67a863be6 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -136,7 +136,7 @@ class FlumeSensor(CoordinatorEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" sensor_key = self._flume_query_sensor[0] if sensor_key not in self._flume_device.values: @@ -145,7 +145,7 @@ class FlumeSensor(CoordinatorEntity, SensorEntity): return _format_state_value(self._flume_device.values[sensor_key]) @property - def unit_of_measurement(self): + 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"] diff --git a/homeassistant/components/flume/translations/es-419.json b/homeassistant/components/flume/translations/es-419.json index 026875846c6..4b63e326d7f 100644 --- a/homeassistant/components/flume/translations/es-419.json +++ b/homeassistant/components/flume/translations/es-419.json @@ -9,6 +9,10 @@ "unknown": "Error inesperado" }, "step": { + "reauth_confirm": { + "description": "La contrase\u00f1a de {username} ya no es v\u00e1lida.", + "title": "Vuelva a autenticar su cuenta de Flume" + }, "user": { "data": { "client_id": "Identificaci\u00f3n del cliente", diff --git a/homeassistant/components/flume/translations/hu.json b/homeassistant/components/flume/translations/hu.json index e607ac4255e..e1780be5654 100644 --- a/homeassistant/components/flume/translations/hu.json +++ b/homeassistant/components/flume/translations/hu.json @@ -19,9 +19,13 @@ }, "user": { "data": { + "client_id": "\u00dcgyf\u00e9lazonos\u00edt\u00f3", + "client_secret": "\u00dcgyf\u00e9l jelszva", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A Flume Personal API el\u00e9r\u00e9s\u00e9hez \u201e\u00dcgyf\u00e9l-azonos\u00edt\u00f3t\u201d \u00e9s \u201e\u00dcgyf\u00e9ltitkot\u201d kell k\u00e9rnie a https://portal.flumetech.com/settings#token c\u00edmen.", + "title": "Csatlakozzon a Flume-fi\u00f3kj\u00e1hoz" } } } diff --git a/homeassistant/components/flume/translations/zh-Hans.json b/homeassistant/components/flume/translations/zh-Hans.json index a5f4ff11f09..db06c3cf23a 100644 --- a/homeassistant/components/flume/translations/zh-Hans.json +++ b/homeassistant/components/flume/translations/zh-Hans.json @@ -1,6 +1,12 @@ { "config": { "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "{username} \u7684\u5bc6\u7801\u5df2\u5931\u6548\u3002" + }, "user": { "data": { "username": "\u7528\u6237\u540d" diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 88fb0147296..98d11b0dc45 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,7 +1,7 @@ """Support for user- and CDC-based flu info sensors from Flu Near You.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -38,20 +38,63 @@ SENSOR_TYPE_USER_NO_SYMPTOMS = "none" SENSOR_TYPE_USER_SYMPTOMS = "symptoms" SENSOR_TYPE_USER_TOTAL = "total" -CDC_SENSORS = [ - (SENSOR_TYPE_CDC_LEVEL, "CDC Level", "mdi:biohazard", None), - (SENSOR_TYPE_CDC_LEVEL2, "CDC Level 2", "mdi:biohazard", None), -] +CDC_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TYPE_CDC_LEVEL, + name="CDC Level", + icon="mdi:biohazard", + ), + SensorEntityDescription( + key=SENSOR_TYPE_CDC_LEVEL2, + name="CDC Level 2", + icon="mdi:biohazard", + ), +) -USER_SENSORS = [ - (SENSOR_TYPE_USER_CHICK, "Avian Flu Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_DENGUE, "Dengue Fever Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_FLU, "Flu Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_LEPTO, "Leptospirosis Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_NO_SYMPTOMS, "No Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_SYMPTOMS, "Flu-like Symptoms", "mdi:alert", "reports"), - (SENSOR_TYPE_USER_TOTAL, "Total Symptoms", "mdi:alert", "reports"), -] +USER_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TYPE_USER_CHICK, + name="Avian Flu Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_DENGUE, + name="Dengue Fever Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_FLU, + name="Flu Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_LEPTO, + name="Leptospirosis Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_NO_SYMPTOMS, + name="No Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_SYMPTOMS, + name="Flu-like Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), + SensorEntityDescription( + key=SENSOR_TYPE_USER_TOTAL, + name="Total Symptoms", + icon="mdi:alert", + native_unit_of_measurement="reports", + ), +) EXTENDED_SENSOR_TYPE_MAPPING = { SENSOR_TYPE_USER_FLU: "ili", @@ -66,32 +109,16 @@ async def async_setup_entry( """Set up Flu Near You sensors based on a config entry.""" coordinators = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] - sensors: list[CdcSensor | UserSensor] = [] - - for (sensor_type, name, icon, unit) in CDC_SENSORS: - sensors.append( - CdcSensor( - coordinators[CATEGORY_CDC_REPORT], - entry, - sensor_type, - name, - icon, - unit, - ) - ) - - for (sensor_type, name, icon, unit) in USER_SENSORS: - sensors.append( - UserSensor( - coordinators[CATEGORY_USER_REPORT], - entry, - sensor_type, - name, - icon, - unit, - ) - ) - + sensors: list[CdcSensor | UserSensor] = [ + CdcSensor(coordinators[CATEGORY_CDC_REPORT], entry, description) + for description in CDC_SENSOR_DESCRIPTIONS + ] + sensors.extend( + [ + UserSensor(coordinators[CATEGORY_USER_REPORT], entry, description) + for description in USER_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -102,23 +129,18 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): self, coordinator: DataUpdateCoordinator, entry: ConfigEntry, - sensor_type: str, - name: str, - icon: str, - unit: str | None, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" 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_LATITUDE]}," - f"{entry.data[CONF_LONGITUDE]}_{sensor_type}" + f"{entry.data[CONF_LONGITUDE]}_{description.key}" ) - self._attr_unit_of_measurement = unit self._entry = entry - self._sensor_type = sensor_type + self.entity_description = description @callback def _handle_coordinator_update(self) -> None: @@ -149,7 +171,7 @@ class CdcSensor(FluNearYouSensor): ATTR_STATE: self.coordinator.data["name"], } ) - self._attr_state = self.coordinator.data[self._sensor_type] + self._attr_native_value = self.coordinator.data[self.entity_description.key] class UserSensor(FluNearYouSensor): @@ -168,10 +190,10 @@ class UserSensor(FluNearYouSensor): } ) - if self._sensor_type in self.coordinator.data["state"]["data"]: - states_key = self._sensor_type - elif self._sensor_type in EXTENDED_SENSOR_TYPE_MAPPING: - states_key = EXTENDED_SENSOR_TYPE_MAPPING[self._sensor_type] + 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 @@ -180,8 +202,8 @@ class UserSensor(FluNearYouSensor): ATTR_STATE_REPORTS_LAST_WEEK ] = self.coordinator.data["state"]["last_week_data"][states_key] - if self._sensor_type == SENSOR_TYPE_USER_TOTAL: - self._attr_state = sum( + if self.entity_description.key == SENSOR_TYPE_USER_TOTAL: + self._attr_native_value = sum( v for k, v in self.coordinator.data["local"].items() if k @@ -194,4 +216,6 @@ class UserSensor(FluNearYouSensor): ) ) else: - self._attr_state = self.coordinator.data["local"][self._sensor_type] + self._attr_native_value = self.coordinator.data["local"][ + self.entity_description.key + ] diff --git a/homeassistant/components/flunearyou/translations/fi.json b/homeassistant/components/flunearyou/translations/fi.json new file mode 100644 index 00000000000..b751fda5e4c --- /dev/null +++ b/homeassistant/components/flunearyou/translations/fi.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Odottamaton virhe" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/hu.json b/homeassistant/components/flunearyou/translations/hu.json index 4f8cca2a939..a67bc91a2a1 100644 --- a/homeassistant/components/flunearyou/translations/hu.json +++ b/homeassistant/components/flunearyou/translations/hu.json @@ -11,7 +11,9 @@ "data": { "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g" - } + }, + "description": "Figyelje a felhaszn\u00e1l\u00f3alap\u00fa \u00e9s a CDC jelent\u00e9seket egy p\u00e1r koordin\u00e1t\u00e1ra.", + "title": "Flu Near You weboldal konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index 707f22f98ba..c7257d40237 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -79,7 +79,7 @@ class Folder(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" decimals = 2 size_mb = round(self._size / 1e6, decimals) @@ -102,6 +102,6 @@ class Folder(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 709e95f476b..9a7967d22cb 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.3"], + "requirements": ["watchdog==2.1.4"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index d635f231818..21510771cd5 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -1,4 +1,6 @@ """Support for the Foobot indoor air quality monitor.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -7,7 +9,7 @@ import aiohttp from foobot_async import FoobotClient import voluptuous as vol -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_TIME, @@ -36,25 +38,49 @@ ATTR_CARBON_DIOXIDE = "CO2" ATTR_VOLATILE_ORGANIC_COMPOUNDS = "VOC" ATTR_FOOBOT_INDEX = "index" -SENSOR_TYPES = { - "time": [ATTR_TIME, TIME_SECONDS, None, None], - "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud", None], - "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "hum": [ATTR_HUMIDITY, PERCENTAGE, "mdi:water-percent", None], - "co2": [ - ATTR_CARBON_DIOXIDE, - CONCENTRATION_PARTS_PER_MILLION, - "mdi:molecule-co2", - None, - ], - "voc": [ - ATTR_VOLATILE_ORGANIC_COMPOUNDS, - CONCENTRATION_PARTS_PER_BILLION, - "mdi:cloud", - None, - ], - "allpollu": [ATTR_FOOBOT_INDEX, PERCENTAGE, "mdi:percent", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="time", + name=ATTR_TIME, + native_unit_of_measurement=TIME_SECONDS, + ), + SensorEntityDescription( + key="pm", + name=ATTR_PM2_5, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:cloud", + ), + SensorEntityDescription( + key="tmp", + name=ATTR_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="hum", + name=ATTR_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="co2", + name=ATTR_CARBON_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:molecule-co2", + ), + SensorEntityDescription( + key="voc", + name=ATTR_VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + icon="mdi:cloud", + ), + SensorEntityDescription( + key="allpollu", + name=ATTR_FOOBOT_INDEX, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), +) SCAN_INTERVAL = timedelta(minutes=10) PARALLEL_UPDATES = 1 @@ -74,17 +100,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= client = FoobotClient( token, username, async_get_clientsession(hass), timeout=TIMEOUT ) - dev = [] + entities = [] try: devices = await client.get_devices() _LOGGER.debug("The following devices were found: %s", devices) for device in devices: foobot_data = FoobotData(client, device["uuid"]) - for sensor_type in SENSOR_TYPES: - if sensor_type == "time": - continue - foobot_sensor = FoobotSensor(foobot_data, device, sensor_type) - dev.append(foobot_sensor) + entities.extend( + [ + FoobotSensor(foobot_data, device, description) + for description in SENSOR_TYPES + if description.key != "time" + ] + ) except ( aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError, @@ -96,54 +124,29 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= except FoobotClient.ClientError: _LOGGER.error("Failed to fetch data from foobot servers") return - async_add_entities(dev, True) + async_add_entities(entities, True) class FoobotSensor(SensorEntity): """Implementation of a Foobot sensor.""" - def __init__(self, data, device, sensor_type): + def __init__(self, data, device, description: SensorEntityDescription): """Initialize the sensor.""" - self._uuid = device["uuid"] + self.entity_description = description self.foobot_data = data - self._name = f"Foobot {device['name']} {SENSOR_TYPES[sensor_type][0]}" - self.type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + self._attr_name = f"Foobot {device['name']} {description.name}" + self._attr_unique_id = f"{device['uuid']}_{description.key}" @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES[self.type][3] - - @property - def icon(self): - """Icon to use in the frontend.""" - return SENSOR_TYPES[self.type][2] - - @property - def state(self): + def native_value(self): """Return the state of the device.""" try: - data = self.foobot_data.data[self.type] + data = self.foobot_data.data[self.entity_description.key] except (KeyError, TypeError): data = None return data - @property - def unique_id(self): - """Return the unique id of this entity.""" - return f"{self._uuid}_{self.type}" - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - async def async_update(self): """Get the latest data.""" await self.foobot_data.async_update() diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 4d996736ecf..9638ea4e4dd 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -5,12 +5,11 @@ from datetime import timedelta import logging from forecast_solar import ForecastSolar -import voluptuous as vol -from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -32,8 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not api_key: api_key = None + session = async_get_clientsession(hass) forecast = ForecastSolar( api_key=api_key, + session=session, latitude=entry.data[CONF_LATITUDE], longitude=entry.data[CONF_LONGITUDE], declination=entry.options[CONF_DECLINATION], @@ -57,10 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - websocket_api.async_register_command(hass, ws_list_forecasts) - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -81,22 +79,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) - - -@websocket_api.websocket_command({vol.Required("type"): "forecast_solar/forecasts"}) -@callback -def ws_list_forecasts( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict -) -> None: - """Return a list of available forecasts.""" - forecasts = {} - - for config_entry_id, coordinator in hass.data[DOMAIN].items(): - forecasts[config_entry_id] = { - "wh_hours": { - timestamp.isoformat(): val - for timestamp, val in coordinator.data.wh_hours.items() - } - } - - connection.send_result(msg["id"], forecasts) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 256534da67a..e7f41777062 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -96,7 +96,11 @@ class ForecastSolarOptionFlowHandler(OptionsFlow): { vol.Optional( CONF_API_KEY, - default=self.config_entry.options.get(CONF_API_KEY, ""), + description={ + "suggested_value": self.config_entry.options.get( + CONF_API_KEY + ) + }, ): str, vol.Required( CONF_DECLINATION, diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index 7ae6fe01d42..ea76ed7da2a 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -30,14 +30,14 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( name="Estimated Energy Production - Today", state=lambda estimate: estimate.energy_production_today / 1000, device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), ForecastSolarSensorEntityDescription( key="energy_production_tomorrow", name="Estimated Energy Production - Tomorrow", state=lambda estimate: estimate.energy_production_tomorrow / 1000, device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), ForecastSolarSensorEntityDescription( key="power_highest_peak_time_today", @@ -55,7 +55,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( device_class=DEVICE_CLASS_POWER, state=lambda estimate: estimate.power_production_now, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), ForecastSolarSensorEntityDescription( key="power_production_next_hour", @@ -65,7 +65,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( name="Estimated Power Production - Next Hour", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), ForecastSolarSensorEntityDescription( key="power_production_next_12hours", @@ -75,7 +75,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( name="Estimated Power Production - Next 12 Hours", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), ForecastSolarSensorEntityDescription( key="power_production_next_24hours", @@ -85,20 +85,20 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( name="Estimated Power Production - Next 24 Hours", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), ForecastSolarSensorEntityDescription( key="energy_current_hour", name="Estimated Energy Production - This Hour", state=lambda estimate: estimate.energy_current_hour / 1000, device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), ForecastSolarSensorEntityDescription( key="energy_next_hour", state=lambda estimate: estimate.sum_energy_production(1) / 1000, name="Estimated Energy Production - Next Hour", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), ) diff --git a/homeassistant/components/forecast_solar/energy.py b/homeassistant/components/forecast_solar/energy.py new file mode 100644 index 00000000000..6bf63910e5f --- /dev/null +++ b/homeassistant/components/forecast_solar/energy.py @@ -0,0 +1,23 @@ +"""Energy platform.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_solar_forecast( + hass: HomeAssistant, config_entry_id: str +) -> dict[str, dict[str, float | int]] | None: + """Get solar forecast for a config entry ID.""" + coordinator = hass.data[DOMAIN].get(config_entry_id) + + if coordinator is None: + return None + + return { + "wh_hours": { + timestamp.isoformat(): val + for timestamp, val in coordinator.data.wh_hours.items() + } + } diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 2b57eed84ac..dc4b88d160c 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -3,7 +3,7 @@ "name": "Forecast.Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/forecast_solar", - "requirements": ["forecast_solar==2.0.0"], + "requirements": ["forecast_solar==2.1.0"], "codeowners": ["@klaasnicolaas", "@frenck"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 5d3f440f4b6..2ad86186652 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -5,7 +5,12 @@ from datetime import datetime from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -56,11 +61,12 @@ class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): ATTR_IDENTIFIERS: {(DOMAIN, entry_id)}, ATTR_NAME: "Solar Production Forecast", ATTR_MANUFACTURER: "Forecast.Solar", + ATTR_MODEL: coordinator.data.account_type.value, ATTR_ENTRY_TYPE: ENTRY_TYPE_SERVICE, } @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" if self.entity_description.state is None: state: StateType | datetime = getattr( diff --git a/homeassistant/components/forecast_solar/translations/cs.json b/homeassistant/components/forecast_solar/translations/cs.json new file mode 100644 index 00000000000..0b970643bbe --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/cs.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka", + "name": "Jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/es-419.json b/homeassistant/components/forecast_solar/translations/es-419.json new file mode 100644 index 00000000000..e2b71af40de --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia pico total en vatios de sus m\u00f3dulos solares" + }, + "description": "Complete los datos de sus paneles solares. Consulte la documentaci\u00f3n si un campo no est\u00e1 claro." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Clave de API Forecast.Solar (opcional)", + "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "damping": "Factor de amortiguaci\u00f3n: ajusta los resultados por la ma\u00f1ana y por la noche", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia pico total en vatios de sus m\u00f3dulos solares" + }, + "description": "Estos valores permiten modificar el resultado de Solar.Forecast. Consulte la documentaci\u00f3n si un campo no est\u00e1 claro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/es.json b/homeassistant/components/forecast_solar/translations/es.json index 2189cb91f77..8a1b51a5084 100644 --- a/homeassistant/components/forecast_solar/translations/es.json +++ b/homeassistant/components/forecast_solar/translations/es.json @@ -1,8 +1,21 @@ { + "config": { + "step": { + "user": { + "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" + }, + "description": "Rellene los datos de sus paneles solares. Consulte la documentaci\u00f3n si alg\u00fan campo no est\u00e1 claro." + } + } + }, "options": { "step": { "init": { "data": { + "api_key": "Clave API de Forecast.Solar (opcional)", "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", "damping": "Factor de amortiguaci\u00f3n: ajusta los resultados por la ma\u00f1ana y por la noche", "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", diff --git a/homeassistant/components/forecast_solar/translations/no.json b/homeassistant/components/forecast_solar/translations/no.json index 5ee0691ecda..1504727c1ae 100644 --- a/homeassistant/components/forecast_solar/translations/no.json +++ b/homeassistant/components/forecast_solar/translations/no.json @@ -24,7 +24,7 @@ "declination": "Deklinasjon (0 = horisontal, 90 = vertikal)", "modules power": "Total Watt-toppeffekt i solcellemodulene dine" }, - "description": "Disse verdiene tillater justering av Solar.Forecast-resultatet. Se dokumentasjonen er et felt som er uklart." + "description": "Disse verdiene tillater justering av Solar.Forecast -resultatet. Se dokumentasjonen hvis et felt er uklart." } } } diff --git a/homeassistant/components/forked_daapd/translations/es-419.json b/homeassistant/components/forked_daapd/translations/es-419.json new file mode 100644 index 00000000000..c62c1889284 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/es-419.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "not_forked_daapd": "El dispositivo no es un servidor daapd bifurcado." + }, + "error": { + "forbidden": "No puede conectarse. Verifique sus permisos de red bifurcados-daapd.", + "websocket_not_enabled": "El websocket del servidor forked-daapd no est\u00e1 habilitado." + }, + "step": { + "user": { + "data": { + "port": "Puerto API" + }, + "title": "Configurar dispositivo bifurcado-daapd" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "Puerto para control de tuber\u00edas librespot-java (si se usa)", + "max_playlists": "N\u00famero m\u00e1ximo de listas de reproducci\u00f3n utilizadas como fuentes", + "tts_pause_time": "Segundos para pausar antes y despu\u00e9s de TTS", + "tts_volume": "Volumen de TTS (flotante en el rango [0,1])" + }, + "description": "Configure varias opciones para la integraci\u00f3n bifurcada-daapd.", + "title": "Configurar las opciones bifurcadas-daapd" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/hu.json b/homeassistant/components/forked_daapd/translations/hu.json index 3400984dcd6..bbf8cb560ff 100644 --- a/homeassistant/components/forked_daapd/translations/hu.json +++ b/homeassistant/components/forked_daapd/translations/hu.json @@ -1,18 +1,41 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "not_forked_daapd": "Az eszk\u00f6z nem forked-daapd kiszolg\u00e1l\u00f3." }, "error": { "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" + "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_password": "Helytelen jelsz\u00f3.", + "wrong_server_type": "A forked-daapd integr\u00e1ci\u00f3hoz forked-daapd szerver sz\u00fcks\u00e9ges, amelynek verzi\u00f3ja> = 27.0." }, "flow_title": "{name} ({host})", "step": { "user": { "data": { - "host": "Hoszt" - } + "host": "Hoszt", + "name": "Megjelen\u00edt\u00e9si n\u00e9v", + "password": "API jelsz\u00f3 (hagyja \u00fcresen, ha nincs jelsz\u00f3)", + "port": "API port" + }, + "title": "\u00c1ll\u00edtsa be a forked-daapd eszk\u00f6zt" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "librespot_java_port": "Port librespot-java cs\u0151 vez\u00e9rl\u00e9s (ha van)", + "max_playlists": "Forr\u00e1sk\u00e9nt haszn\u00e1lt lej\u00e1tsz\u00e1si list\u00e1k maxim\u00e1lis sz\u00e1ma", + "tts_pause_time": "M\u00e1sodpercek a TTS el\u0151tti \u00e9s ut\u00e1ni sz\u00fcnethez", + "tts_volume": "TTS hanger\u0151 (lebeg\u0151 a [0,1] tartom\u00e1nyban)" + }, + "description": "A forked-daapd integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sai.", + "title": "A forked-daapd be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/forked_daapd/translations/zh-Hans.json b/homeassistant/components/forked_daapd/translations/zh-Hans.json new file mode 100644 index 00000000000..9b2bd981397 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/zh-Hans.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "not_forked_daapd": "\u6b64\u8bbe\u5907\u4e0d\u662f\u4e00\u4e2a forked-daapd \u670d\u52a1\u5668\u3002" + }, + "error": { + "forbidden": "\u65e0\u6cd5\u8fde\u63a5\u3002\u8bf7\u68c0\u67e5\u60a8\u7684 forked-daapd \u7f51\u7edc\u6743\u9650\u3002", + "websocket_not_enabled": "\u672a\u542f\u7528 forked-daapd \u670d\u52a1\u5668\u7684 Websocket \u529f\u80fd\u3002", + "wrong_server_type": "forked-daapd \u96c6\u6210\u9700\u8981 forked-daapd \u670d\u52a1\u5668\u7248\u672c\u53f7\u81f3\u5c11\u5927\u4e8e\u6216\u7b49\u4e8e 27.0 \u3002" + }, + "step": { + "user": { + "title": "\u8bbe\u7f6e forked-daapd \u8bbe\u5907" + } + } + }, + "options": { + "step": { + "init": { + "description": "\u4e3a forked-daapd \u96c6\u6210\u8bbe\u7f6e\u5404\u79cd\u9009\u9879\u3002", + "title": "\u914d\u7f6e forked-daapd" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 31ac8c2cad9..7a1e1037ddb 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,4 +1,6 @@ """This component provides basic support for Foscam IP cameras.""" +from __future__ import annotations + import asyncio from libpyfoscam import FoscamCamera @@ -172,7 +174,9 @@ class HassFoscamCamera(Camera): """Return the entity unique ID.""" return self._unique_id - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed diff --git a/homeassistant/components/foscam/translations/es-419.json b/homeassistant/components/foscam/translations/es-419.json new file mode 100644 index 00000000000..39027bdf914 --- /dev/null +++ b/homeassistant/components/foscam/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_response": "Respuesta no v\u00e1lida del dispositivo" + }, + "step": { + "user": { + "data": { + "rtsp_port": "Puerto RTSP", + "stream": "Stream" + } + } + } + }, + "title": "Foscam" +} \ No newline at end of file diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index e68f7208538..939c53b47db 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -109,12 +109,12 @@ class FreeboxSensor(SensorEntity): return self._name @property - def state(self) -> str: + def native_value(self) -> str: """Return the state.""" return self._state @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit.""" return self._unit diff --git a/homeassistant/components/freebox/translations/hu.json b/homeassistant/components/freebox/translations/hu.json index 1f0b848d3b6..c929d56f38e 100644 --- a/homeassistant/components/freebox/translations/hu.json +++ b/homeassistant/components/freebox/translations/hu.json @@ -9,6 +9,10 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "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)", + "title": "Freebox \u00fatv\u00e1laszt\u00f3 linkel\u00e9se" + }, "user": { "data": { "host": "Hoszt", diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py index 0c12f20849c..e5322924864 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -64,8 +64,8 @@ class Device(CoordinatorEntity, SensorEntity): } self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] self._attr_state_class = STATE_CLASS_MAP[device["type"]] - self._attr_unit_of_measurement = UNIT_MAP[device["type"]] - self._attr_state = 0 + self._attr_native_unit_of_measurement = UNIT_MAP[device["type"]] + self._attr_native_value = 0 @callback def _handle_coordinator_update(self) -> None: @@ -80,7 +80,7 @@ class Device(CoordinatorEntity, SensorEntity): ) if device is not None and "state" in device: state = device["state"] - self._attr_state = state[DEVICE_KEY_MAP[self._type]] + self._attr_native_value = state[DEVICE_KEY_MAP[self._type]] super()._handle_coordinator_update() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/freedompro/translations/cs.json b/homeassistant/components/freedompro/translations/cs.json new file mode 100644 index 00000000000..24f35743b7b --- /dev/null +++ b/homeassistant/components/freedompro/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/es-419.json b/homeassistant/components/freedompro/translations/es-419.json new file mode 100644 index 00000000000..ed1317689fe --- /dev/null +++ b/homeassistant/components/freedompro/translations/es-419.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Ingrese la clave API obtenida de https://home.freedompro.eu", + "title": "Clave de API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 8b3f9106602..4ae8314113f 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -6,6 +6,8 @@ PLATFORMS = ["binary_sensor", "device_tracker", "sensor", "switch"] DATA_FRITZ = "fritz_data" +DSL_CONNECTION = "dsl" + DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.178.1" DEFAULT_PORT = 49000 diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index d7a34564b43..bc579b1125e 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,23 +5,33 @@ import datetime import logging from typing import Callable, TypedDict -from fritzconnection.core.exceptions import FritzConnectionException +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzActionFailedError, + FritzConnectionException, + FritzServiceError, +) from fritzconnection.lib.fritzstatus import FritzStatus -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_GIGABYTES, DATA_RATE_KILOBITS_PER_SECOND, DATA_RATE_KILOBYTES_PER_SECOND, DEVICE_CLASS_TIMESTAMP, + SIGNAL_STRENGTH_DECIBELS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow from .common import FritzBoxBaseEntity, FritzBoxTools -from .const import DOMAIN, UPTIME_DEVIATION +from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) @@ -89,16 +99,54 @@ def _retrieve_gb_received_state(status: FritzStatus, last_value: str) -> float: return round(status.bytes_received / 1000 / 1000 / 1000, 1) # type: ignore[no-any-return] +def _retrieve_link_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload link rate.""" + return round(status.max_linked_bit_rate[0] / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_link_kb_s_received_state(status: FritzStatus, last_value: str) -> float: + """Return download link rate.""" + return round(status.max_linked_bit_rate[1] / 1000, 1) # type: ignore[no-any-return] + + +def _retrieve_link_noise_margin_sent_state( + status: FritzStatus, last_value: str +) -> float: + """Return upload noise margin.""" + return status.noise_margin[0] / 10 # type: ignore[no-any-return] + + +def _retrieve_link_noise_margin_received_state( + status: FritzStatus, last_value: str +) -> float: + """Return download noise margin.""" + return status.noise_margin[1] / 10 # type: ignore[no-any-return] + + +def _retrieve_link_attenuation_sent_state( + status: FritzStatus, last_value: str +) -> float: + """Return upload line attenuation.""" + return status.attenuation[0] / 10 # type: ignore[no-any-return] + + +def _retrieve_link_attenuation_received_state( + status: FritzStatus, last_value: str +) -> float: + """Return download line attenuation.""" + return status.attenuation[1] / 10 # type: ignore[no-any-return] + + class SensorData(TypedDict, total=False): """Sensor data class.""" name: str device_class: str | None state_class: str | None - last_reset: bool unit_of_measurement: str | None icon: str | None state_provider: Callable + connection_type: str | None SENSOR_DATA = { @@ -118,47 +166,87 @@ SENSOR_DATA = { state_provider=_retrieve_connection_uptime_state, ), "kb_s_sent": SensorData( - name="kB/s sent", + name="Upload Throughput", state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:upload", state_provider=_retrieve_kb_s_sent_state, ), "kb_s_received": SensorData( - name="kB/s received", + name="Download Throughput", state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, icon="mdi:download", state_provider=_retrieve_kb_s_received_state, ), "max_kb_s_sent": SensorData( - name="Max kbit/s sent", + name="Max Connection Upload Throughput", unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:upload", state_provider=_retrieve_max_kb_s_sent_state, ), "max_kb_s_received": SensorData( - name="Max kbit/s received", + name="Max Connection Download Throughput", unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, icon="mdi:download", state_provider=_retrieve_max_kb_s_received_state, ), "gb_sent": SensorData( name="GB sent", - state_class=STATE_CLASS_MEASUREMENT, - last_reset=True, + state_class=STATE_CLASS_TOTAL_INCREASING, unit_of_measurement=DATA_GIGABYTES, icon="mdi:upload", state_provider=_retrieve_gb_sent_state, ), "gb_received": SensorData( name="GB received", - state_class=STATE_CLASS_MEASUREMENT, - last_reset=True, + state_class=STATE_CLASS_TOTAL_INCREASING, unit_of_measurement=DATA_GIGABYTES, icon="mdi:download", state_provider=_retrieve_gb_received_state, ), + "link_kb_s_sent": SensorData( + name="Link Upload Throughput", + unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:upload", + state_provider=_retrieve_link_kb_s_sent_state, + connection_type=DSL_CONNECTION, + ), + "link_kb_s_received": SensorData( + name="Link Download Throughput", + unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:download", + state_provider=_retrieve_link_kb_s_received_state, + connection_type=DSL_CONNECTION, + ), + "link_noise_margin_sent": SensorData( + name="Link Upload Noise Margin", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:upload", + state_provider=_retrieve_link_noise_margin_sent_state, + connection_type=DSL_CONNECTION, + ), + "link_noise_margin_received": SensorData( + name="Link Download Noise Margin", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:download", + state_provider=_retrieve_link_noise_margin_received_state, + connection_type=DSL_CONNECTION, + ), + "link_attenuation_sent": SensorData( + name="Link Upload Power Attenuation", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:upload", + state_provider=_retrieve_link_attenuation_sent_state, + connection_type=DSL_CONNECTION, + ), + "link_attenuation_received": SensorData( + name="Link Download Power Attenuation", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:download", + state_provider=_retrieve_link_attenuation_received_state, + connection_type=DSL_CONNECTION, + ), } @@ -177,7 +265,20 @@ async def async_setup_entry( return entities = [] - for sensor_type in SENSOR_DATA: + dsl: bool = False + try: + dslinterface = await hass.async_add_executor_job( + fritzbox_tools.connection.call_action, + "WANDSLInterfaceConfig:1", + "GetInfo", + ) + dsl = dslinterface["NewEnable"] + except (FritzActionError, FritzActionFailedError, FritzServiceError): + 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)) if entities: @@ -193,13 +294,14 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): """Init FRITZ!Box connectivity class.""" self._sensor_data: SensorData = SENSOR_DATA[sensor_type] self._last_device_value: str | None = None - self._last_wan_value: str | None = None self._attr_available = True self._attr_device_class = self._sensor_data.get("device_class") self._attr_icon = self._sensor_data.get("icon") self._attr_name = f"{device_friendly_name} {self._sensor_data['name']}" self._attr_state_class = self._sensor_data.get("state_class") - self._attr_unit_of_measurement = self._sensor_data.get("unit_of_measurement") + self._attr_native_unit_of_measurement = self._sensor_data.get( + "unit_of_measurement" + ) self._attr_unique_id = f"{fritzbox_tools.unique_id}-{sensor_type}" super().__init__(fritzbox_tools, device_friendly_name) @@ -220,15 +322,6 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): self._attr_available = False return - self._attr_state = self._last_device_value = self._state_provider( + self._attr_native_value = self._last_device_value = self._state_provider( status, self._last_device_value ) - - if self._sensor_data.get("last_reset") is True: - self._last_wan_value = _retrieve_connection_uptime_state( - status, self._last_wan_value - ) - self._attr_last_reset = datetime.datetime.strptime( - self._last_wan_value, - "%Y-%m-%dT%H:%M:%S%z", - ) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index da17bef7159..430817d4506 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -408,11 +408,10 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity): """Turn off switch.""" await self._async_handle_turn_on_off(turn_on=False) - async def _async_handle_turn_on_off(self, turn_on: bool) -> bool: + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: """Handle switch state change request.""" await self._switch(turn_on) self._attr_is_on = turn_on - return True class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): @@ -468,9 +467,9 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): self._is_available = True attributes_dict = { - "NewInternalClient": "internalIP", - "NewInternalPort": "internalPort", - "NewExternalPort": "externalPort", + "NewInternalClient": "internal_ip", + "NewInternalPort": "internal_port", + "NewExternalPort": "external_port", "NewProtocol": "protocol", "NewPortMappingDescription": "description", } @@ -547,15 +546,15 @@ class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity): self._attr_is_on = self.dict_of_deflection["Enable"] == "1" self._is_available = True - self._attributes["Type"] = self.dict_of_deflection["Type"] - self._attributes["Number"] = self.dict_of_deflection["Number"] - self._attributes["DeflectionToNumber"] = self.dict_of_deflection[ + self._attributes["type"] = self.dict_of_deflection["Type"] + self._attributes["number"] = self.dict_of_deflection["Number"] + self._attributes["deflection_to_number"] = self.dict_of_deflection[ "DeflectionToNumber" ] # Return mode sample: "eImmediately" - self._attributes["Mode"] = self.dict_of_deflection["Mode"][1:] - self._attributes["Outgoing"] = self.dict_of_deflection["Outgoing"] - self._attributes["PhonebookID"] = self.dict_of_deflection["PhonebookID"] + self._attributes["mode"] = self.dict_of_deflection["Mode"][1:] + self._attributes["outgoing"] = self.dict_of_deflection["Outgoing"] + self._attributes["phonebook_id"] = self.dict_of_deflection["PhonebookID"] async def _async_switch_on_off_executor(self, turn_on: bool) -> None: """Handle deflection switch.""" @@ -674,7 +673,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch, SwitchEntity): std = wifi_info["NewStandard"] self._attributes["standard"] = std if std else None - self._attributes["BSSID"] = wifi_info["NewBSSID"] + self._attributes["bssid"] = wifi_info["NewBSSID"] self._attributes["mac_address_control"] = wifi_info[ "NewMACAddressControlEnabled" ] diff --git a/homeassistant/components/fritz/translations/es-419.json b/homeassistant/components/fritz/translations/es-419.json new file mode 100644 index 00000000000..94412f031e6 --- /dev/null +++ b/homeassistant/components/fritz/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "description": "Configure las herramientas de FRITZ! Box para controlar su FRITZ! Box.\nM\u00ednimo necesario: nombre de usuario, contrase\u00f1a.", + "title": "Configurar las herramientas de FRITZ! Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Segundos para considerar un dispositivo en 'casa'" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/zh-Hans.json b/homeassistant/components/fritz/translations/zh-Hans.json new file mode 100644 index 00000000000..91d68989675 --- /dev/null +++ b/homeassistant/components/fritz/translations/zh-Hans.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "confirm": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "start_config": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "\u914d\u7f6e FRITZ!Box Tool \u4ee5\u63a7\u5236\u60a8\u7684 FRITZ!Box\u3002\n\u6700\u4f4e\u4fe1\u606f\u63d0\u4f9b\u8981\u6c42\uff1a\u7528\u6237\u540d\u3001\u5bc6\u7801\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index cef325a61f3..ce5e74cfeec 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -12,7 +12,6 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, @@ -139,7 +138,6 @@ class FritzBoxEntity(CoordinatorEntity): self.ain = ain self._name = entity_info[ATTR_NAME] self._unique_id = entity_info[ATTR_ENTITY_ID] - self._unit_of_measurement = entity_info[ATTR_UNIT_OF_MEASUREMENT] self._device_class = entity_info[ATTR_DEVICE_CLASS] self._attr_state_class = entity_info[ATTR_STATE_CLASS] @@ -174,11 +172,6 @@ class FritzBoxEntity(CoordinatorEntity): """Return the name of the device.""" return self._name - @property - def unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - return self._unit_of_measurement - @property def device_class(self) -> str | None: """Return the device class.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 9d78afca4de..09a652d64ad 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,11 +1,12 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations -from datetime import datetime +from pyfritzhome import FritzhomeDevice from homeassistant.components.sensor import ( ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utc_from_timestamp +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import FritzBoxEntity from .const import ( @@ -34,7 +35,7 @@ from .const import ( CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN, ) -from .model import SensorExtraAttributes +from .model import EntityInfo, SensorExtraAttributes async def async_setup_entry( @@ -96,7 +97,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}_total_energy", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, coordinator, ain, @@ -106,48 +107,56 @@ async def async_setup_entry( async_add_entities(entities) -class FritzBoxBatterySensor(FritzBoxEntity, SensorEntity): +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.""" @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self.device.battery_level # type: ignore [no-any-return] -class FritzBoxPowerSensor(FritzBoxEntity, SensorEntity): +class FritzBoxPowerSensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome power consumption sensors.""" @property - def state(self) -> float | None: + 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(FritzBoxEntity, SensorEntity): +class FritzBoxEnergySensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome total energy sensors.""" @property - def state(self) -> float | None: + 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 - @property - def last_reset(self) -> datetime: - """Return the time when the sensor was last reset, if any.""" - # device does not provide timestamp of initialization - return utc_from_timestamp(0) - -class FritzBoxTempSensor(FritzBoxEntity, SensorEntity): +class FritzBoxTempSensor(FritzBoxSensor): """The entity class for FRITZ!SmartHome temperature sensors.""" @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" return self.device.temperature # type: ignore [no-any-return] diff --git a/homeassistant/components/fritzbox/translations/es-419.json b/homeassistant/components/fritzbox/translations/es-419.json index f66a3dc0dd0..da929ac9983 100644 --- a/homeassistant/components/fritzbox/translations/es-419.json +++ b/homeassistant/components/fritzbox/translations/es-419.json @@ -14,6 +14,9 @@ }, "description": "\u00bfDesea configurar {name}?" }, + "reauth_confirm": { + "description": "Actualice su informaci\u00f3n de inicio de sesi\u00f3n para {name}." + }, "user": { "data": { "host": "Host o direcci\u00f3n IP", diff --git a/homeassistant/components/fritzbox/translations/hu.json b/homeassistant/components/fritzbox/translations/hu.json index 81639b1d830..50a81601310 100644 --- a/homeassistant/components/fritzbox/translations/hu.json +++ b/homeassistant/components/fritzbox/translations/hu.json @@ -4,6 +4,7 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 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" }, "error": { @@ -30,7 +31,8 @@ "host": "Hoszt", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "Adja meg az AVM FRITZ! Box adatait." } } } diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 63b3cd81aa5..31e04077656 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -158,7 +158,7 @@ class FritzBoxCallSensor(SensorEntity): return self._fritzbox_phonebook is not None @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/fritzbox_callmonitor/translations/es-419.json b/homeassistant/components/fritzbox_callmonitor/translations/es-419.json new file mode 100644 index 00000000000..8b10d7d7c2a --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "insufficient_permissions": "El usuario no tiene permisos suficientes para acceder a la configuraci\u00f3n de AVM FRITZ! Box y sus agendas telef\u00f3nicas." + }, + "flow_title": "{name}", + "step": { + "phonebook": { + "data": { + "phonebook": "Directorio telef\u00f3nico" + } + } + } + }, + "options": { + "error": { + "malformed_prefixes": "Los prefijos est\u00e1n mal formados, verifique su formato." + }, + "step": { + "init": { + "data": { + "prefixes": "Prefijos (lista separada por comas)" + }, + "title": "Configurar prefijos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 1ae95d30fd5..4c21e83e191 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -2,7 +2,7 @@ "domain": "fronius", "name": "Fronius", "documentation": "https://www.home-assistant.io/integrations/fronius", - "requirements": ["pyfronius==0.5.3"], + "requirements": ["pyfronius==0.6.0"], "codeowners": ["@nielstron"], "iot_class": "local_polling" } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 6f949334d02..076eee9acc8 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import copy from datetime import timedelta import logging +from typing import Any from pyfronius import Fronius import voluptuous as vol @@ -11,8 +12,8 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, - SensorEntityDescription, ) from homeassistant.const import ( CONF_DEVICE, @@ -20,14 +21,20 @@ from homeassistant.const import ( CONF_RESOURCE, CONF_SCAN_INTERVAL, CONF_SENSOR_TYPE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLTAGE, ) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) @@ -37,6 +44,7 @@ TYPE_INVERTER = "inverter" TYPE_STORAGE = "storage" TYPE_METER = "meter" TYPE_POWER_FLOW = "power_flow" +TYPE_LOGGER_INFO = "logger_info" SCOPE_DEVICE = "device" SCOPE_SYSTEM = "system" @@ -45,9 +53,37 @@ DEFAULT_DEVICE = 0 DEFAULT_INVERTER = 1 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) -SENSOR_TYPES = [TYPE_INVERTER, TYPE_STORAGE, TYPE_METER, TYPE_POWER_FLOW] +SENSOR_TYPES = [ + TYPE_INVERTER, + TYPE_STORAGE, + TYPE_METER, + TYPE_POWER_FLOW, + TYPE_LOGGER_INFO, +] SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] +PREFIX_DEVICE_CLASS_MAPPING = [ + ("state_of_charge", DEVICE_CLASS_BATTERY), + ("temperature", DEVICE_CLASS_TEMPERATURE), + ("power_factor", DEVICE_CLASS_POWER_FACTOR), + ("power", DEVICE_CLASS_POWER), + ("energy", DEVICE_CLASS_ENERGY), + ("current", DEVICE_CLASS_CURRENT), + ("timestamp", DEVICE_CLASS_TIMESTAMP), + ("voltage", DEVICE_CLASS_VOLTAGE), +] + +PREFIX_STATE_CLASS_MAPPING = [ + ("state_of_charge", STATE_CLASS_MEASUREMENT), + ("temperature", STATE_CLASS_MEASUREMENT), + ("power_factor", STATE_CLASS_MEASUREMENT), + ("power", STATE_CLASS_MEASUREMENT), + ("energy", STATE_CLASS_TOTAL_INCREASING), + ("current", STATE_CLASS_MEASUREMENT), + ("timestamp", STATE_CLASS_MEASUREMENT), + ("voltage", STATE_CLASS_MEASUREMENT), +] + def _device_id_validator(config): """Ensure that inverters have default id 1 and other devices 0.""" @@ -111,6 +147,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= adapter_cls = FroniusMeterDevice elif sensor_type == TYPE_POWER_FLOW: adapter_cls = FroniusPowerFlow + elif sensor_type == TYPE_LOGGER_INFO: + adapter_cls = FroniusLoggerInfo else: adapter_cls = FroniusStorage @@ -134,16 +172,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class FroniusAdapter: """The Fronius sensor fetching component.""" - def __init__(self, bridge, name, device, add_entities): + def __init__( + self, bridge: Fronius, name: str, device: int, add_entities: AddEntitiesCallback + ) -> None: """Initialize the sensor.""" self.bridge = bridge self._name = name self._device = device - self._fetched = {} + self._fetched: dict[str, Any] = {} self._available = True - self.sensors = set() - self._registered_sensors = set() + self.sensors: set[str] = set() + self._registered_sensors: set[SensorEntity] = set() self._add_entities = add_entities @property @@ -161,12 +201,6 @@ class FroniusAdapter: """Whether the fronius device is active.""" return self._available - def entity_description( # pylint: disable=no-self-use - self, key - ) -> SensorEntityDescription | None: - """Create entity description for a key.""" - return None - async def async_update(self): """Retrieve and update latest state.""" try: @@ -223,18 +257,6 @@ class FroniusAdapter: class FroniusInverterSystem(FroniusAdapter): """Adapter for the fronius inverter with system scope.""" - def entity_description(self, key): - """Return the entity descriptor.""" - if key != "energy_total": - return None - - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_inverter_data() @@ -243,18 +265,6 @@ class FroniusInverterSystem(FroniusAdapter): class FroniusInverterDevice(FroniusAdapter): """Adapter for the fronius inverter with device scope.""" - def entity_description(self, key): - """Return the entity descriptor.""" - if key != "energy_total": - return None - - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_inverter_data(self._device) @@ -271,18 +281,6 @@ class FroniusStorage(FroniusAdapter): class FroniusMeterSystem(FroniusAdapter): """Adapter for the fronius meter with system scope.""" - def entity_description(self, key): - """Return the entity descriptor.""" - if not key.startswith("energy_real_"): - return None - - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_meter_data() @@ -291,18 +289,6 @@ class FroniusMeterSystem(FroniusAdapter): class FroniusMeterDevice(FroniusAdapter): """Adapter for the fronius meter with device scope.""" - def entity_description(self, key): - """Return the entity descriptor.""" - if not key.startswith("energy_real_"): - return None - - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_meter_data(self._device) @@ -311,29 +297,35 @@ class FroniusMeterDevice(FroniusAdapter): class FroniusPowerFlow(FroniusAdapter): """Adapter for the fronius power flow.""" - def entity_description(self, key): - """Return the entity descriptor.""" - return SensorEntityDescription( - key=key, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ) - async def _update(self): """Get the values for the current state.""" return await self.bridge.current_power_flow() +class FroniusLoggerInfo(FroniusAdapter): + """Adapter for the fronius power flow.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_logger_info() + + class FroniusTemplateSensor(SensorEntity): """Sensor for the single values (e.g. pv power, ac power).""" - def __init__(self, parent: FroniusAdapter, key): + def __init__(self, parent: FroniusAdapter, key: str) -> None: """Initialize a singular value sensor.""" self._key = key self._attr_name = f"{key.replace('_', ' ').capitalize()} {parent.name}" self._parent = parent - if entity_description := parent.entity_description(key): - self.entity_description = entity_description + for prefix, device_class in PREFIX_DEVICE_CLASS_MAPPING: + if self._key.startswith(prefix): + self._attr_device_class = device_class + break + for prefix, state_class in PREFIX_STATE_CLASS_MAPPING: + if self._key.startswith(prefix): + self._attr_state_class = state_class + break @property def should_poll(self): @@ -348,10 +340,10 @@ class FroniusTemplateSensor(SensorEntity): async def async_update(self): """Update the internal state.""" state = self._parent.data.get(self._key) - self._attr_state = state.get("value") - if isinstance(self._attr_state, float): - self._attr_state = round(self._attr_state, 2) - self._attr_unit_of_measurement = state.get("unit") + self._attr_native_value = state.get("value") + if isinstance(self._attr_native_value, float): + self._attr_native_value = round(self._attr_native_value, 2) + self._attr_native_unit_of_measurement = state.get("unit") async def async_added_to_hass(self): """Register at parent component for updates.""" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 135c0ec0244..076420656fd 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==20210809.0" + "home-assistant-frontend==20210830.0" ], "dependencies": [ "api", diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index ed01862aba4..da3a7a4dc24 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -76,7 +76,7 @@ class GaragesamsterdamSensor(CoordinatorEntity, SensorEntity): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return getattr(self.coordinator.data[self._garage_name], self._info_type) @@ -86,7 +86,7 @@ class GaragesamsterdamSensor(CoordinatorEntity, SensorEntity): return SENSORS[self._info_type] @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return unit of measurement.""" return "cars" diff --git a/homeassistant/components/garages_amsterdam/translations/es-419.json b/homeassistant/components/garages_amsterdam/translations/es-419.json new file mode 100644 index 00000000000..ef74816d2fc --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "garage_name": "Nombre del garaje" + }, + "title": "Elija un garaje para monitorear" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gc100/manifest.json b/homeassistant/components/gc100/manifest.json index 55ea7d94682..8caa2f91204 100644 --- a/homeassistant/components/gc100/manifest.json +++ b/homeassistant/components/gc100/manifest.json @@ -2,7 +2,7 @@ "domain": "gc100", "name": "Global Cach\u00e9 GC-100", "documentation": "https://www.home-assistant.io/integrations/gc100", - "requirements": ["python-gc100==1.0.3a"], + "requirements": ["python-gc100==1.0.3a0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 2e4759088fc..8b4c60046db 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -105,7 +105,7 @@ class GdacsSensor(SensorEntity): self._removed = status_info.removed @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._total @@ -125,7 +125,7 @@ class GdacsSensor(SensorEntity): return DEFAULT_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEFAULT_UNIT_OF_MEASUREMENT diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 56b490e165a..b6e08ea8582 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,4 +1,6 @@ """Support for IP Cameras.""" +from __future__ import annotations + import asyncio import logging @@ -118,13 +120,17 @@ class GenericCamera(Camera): """Return the interval between frames of the mjpeg stream.""" return self._frame_interval - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" return asyncio.run_coroutine_threadsafe( self.async_camera_image(), self.hass.loop ).result() - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: url = self._still_image_url.async_render(parse_result=False) diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index 0c96ec595b6..362e729f57a 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -79,12 +79,12 @@ class GeniusBattery(GeniusDevice, SensorEntity): return DEVICE_CLASS_BATTERY @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of the sensor.""" return PERCENTAGE @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" level = self._device.data["state"][self._state_attr] return level if level != 255 else 0 @@ -105,7 +105,7 @@ class GeniusIssue(GeniusEntity, SensorEntity): self._issues = [] @property - def state(self) -> str: + def native_value(self) -> str: """Return the number of issues.""" return len(self._issues) diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index df5f11850fd..f5797121603 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -121,12 +121,12 @@ class GeoRssServiceSensor(SensorEntity): return f"{self._service_name} {'Any' if self._category is None else self._category}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 94c7965663a..605f56b1272 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -106,7 +106,7 @@ class GeonetnzQuakesSensor(SensorEntity): self._removed = status_info.removed @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._total @@ -126,7 +126,7 @@ class GeonetnzQuakesSensor(SensorEntity): return DEFAULT_ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEFAULT_UNIT_OF_MEASUREMENT diff --git a/homeassistant/components/geonetnz_quakes/translations/hu.json b/homeassistant/components/geonetnz_quakes/translations/hu.json index 21a38c18e28..d6070db4fe7 100644 --- a/homeassistant/components/geonetnz_quakes/translations/hu.json +++ b/homeassistant/components/geonetnz_quakes/translations/hu.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "mmi": "MMI", "radius": "Sug\u00e1r" }, "title": "T\u00f6ltsd ki a sz\u0171r\u0151 adatait." diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index c0cc6801437..fc9f0f30b2c 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -130,7 +130,7 @@ class GeonetnzVolcanoSensor(SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._alert_level @@ -145,7 +145,7 @@ class GeonetnzVolcanoSensor(SensorEntity): return f"Volcano {self._title}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return "alert level" diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 9b890442166..4f19b0d8a68 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -5,7 +5,16 @@ from datetime import timedelta from typing import Final from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CO, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_SULPHUR_DIOXIDE, +) from .model import GiosSensorEntityDescription @@ -36,48 +45,56 @@ SENSOR_TYPES: Final[tuple[GiosSensorEntityDescription, ...]] = ( GiosSensorEntityDescription( key=ATTR_AQI, name="AQI", + device_class=DEVICE_CLASS_AQI, value=None, ), GiosSensorEntityDescription( key=ATTR_C6H6, name="C6H6", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:molecule", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_CO, name="CO", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_CO, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_NO2, name="NO2", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_NITROGEN_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_O3, name="O3", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_OZONE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_PM10, name="PM10", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_PM25, name="PM2.5", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_SO2, name="SO2", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_SULPHUR_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=STATE_CLASS_MEASUREMENT, ), ) diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index b651112b9db..9ba5e5410b0 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -86,7 +86,6 @@ class GiosSensor(CoordinatorEntity, SensorEntity): "manufacturer": MANUFACTURER, "entry_type": "service", } - self._attr_icon = "mdi:blur" self._attr_name = f"{name} {description.name}" self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" self._attrs: dict[str, Any] = { @@ -107,7 +106,7 @@ class GiosSensor(CoordinatorEntity, SensorEntity): return self._attrs @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" state = getattr(self.coordinator.data, self.entity_description.key).value assert self.entity_description.value is not None @@ -118,7 +117,7 @@ class GiosAqiSensor(GiosSensor): """Define an GIOS AQI sensor.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" return cast( StateType, getattr(self.coordinator.data, self.entity_description.key).value diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index d4405196b7a..ce2ad4e047b 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -2,7 +2,12 @@ "domain": "github", "name": "GitHub", "documentation": "https://www.home-assistant.io/integrations/github", - "requirements": ["PyGithub==1.43.8"], - "codeowners": [], + "requirements": [ + "aiogithubapi==21.8.0" + ], + "codeowners": [ + "@timmo001", + "@ludeeus" + ], "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index c7812fa621d..56cd7137504 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -1,8 +1,11 @@ -"""Support for GitHub.""" +"""Sensor platform for the GitHub integratiom.""" +from __future__ import annotations + +import asyncio from datetime import timedelta import logging -import github +from aiogithubapi import GitHubAPI, GitHubException import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -13,6 +16,7 @@ from homeassistant.const import ( CONF_PATH, CONF_URL, ) +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -52,23 +56,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the GitHub sensor platform.""" sensors = [] + session = async_get_clientsession(hass) for repository in config[CONF_REPOS]: data = GitHubData( repository=repository, - access_token=config.get(CONF_ACCESS_TOKEN), + access_token=config[CONF_ACCESS_TOKEN], + session=session, server_url=config.get(CONF_URL), ) - if data.setup_error is True: - _LOGGER.error( - "Error setting up GitHub platform. %s", - "Check previous errors for details", - ) - else: - sensors.append(GitHubSensor(data)) - add_entities(sensors, True) + sensors.append(GitHubSensor(data)) + async_add_entities(sensors, True) class GitHubSensor(SensorEntity): @@ -108,7 +108,7 @@ class GitHubSensor(SensorEntity): return self._unique_id @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -121,7 +121,7 @@ class GitHubSensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" attrs = { - ATTR_PATH: self._repository_path, + 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, @@ -150,122 +150,164 @@ class GitHubSensor(SensorEntity): """Return the icon to use in the frontend.""" return "mdi:github" - def update(self): + async def async_update(self): """Collect updated data from GitHub API.""" - self._github_data.update() + await self._github_data.async_update() + self._available = self._github_data.available + if not self._available: + return self._name = self._github_data.name - self._repository_path = self._github_data.repository_path - self._available = self._github_data.available - self._latest_commit_message = self._github_data.latest_commit_message - self._latest_commit_sha = self._github_data.latest_commit_sha - if self._github_data.latest_release_url is not None: - self._latest_release_tag = self._github_data.latest_release_url.split( - "tag/" - )[1] - else: - self._latest_release_tag = None - self._latest_release_url = self._github_data.latest_release_url - self._state = self._github_data.latest_commit_sha[0:7] - self._open_issue_count = self._github_data.open_issue_count - self._latest_open_issue_url = self._github_data.latest_open_issue_url - self._pull_request_count = self._github_data.pull_request_count - self._latest_open_pr_url = self._github_data.latest_open_pr_url - self._stargazers = self._github_data.stargazers - self._forks = self._github_data.forks - self._clones = self._github_data.clones - self._clones_unique = self._github_data.clones_unique - self._views = self._github_data.views - self._views_unique = self._github_data.views_unique + self._state = 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 + self._stargazers = self._github_data.repository_response.data.stargazers_count + self._forks = self._github_data.repository_response.data.forks_count + + self._pull_request_count = len(self._github_data.pulls_response.data) + self._open_issue_count = ( + self._github_data.repository_response.data.open_issues_count or 0 + ) - self._pull_request_count + + if self._github_data.last_release: + self._latest_release_tag = self._github_data.last_release.tag_name + self._latest_release_url = self._github_data.last_release.html_url + + if self._github_data.last_issue: + self._latest_open_issue_url = self._github_data.last_issue.html_url + + if self._github_data.last_pull_request: + self._latest_open_pr_url = self._github_data.last_pull_request.html_url + + if self._github_data.clones_response: + self._clones = self._github_data.clones_response.data.count + self._clones_unique = self._github_data.clones_response.data.uniques + + if self._github_data.views_response: + self._views = self._github_data.views_response.data.count + self._views_unique = self._github_data.views_response.data.uniques class GitHubData: """GitHub Data object.""" - def __init__(self, repository, access_token=None, server_url=None): + def __init__(self, repository, access_token, session, server_url=None): """Set up GitHub.""" - self._github = github + self._repository = repository + self.repository_path = repository[CONF_PATH] + self._github = GitHubAPI( + token=access_token, session=session, **{"base_url": server_url} + ) - self.setup_error = False - - try: - if server_url is not None: - server_url += "/api/v3" - self._github_obj = github.Github(access_token, base_url=server_url) - else: - self._github_obj = github.Github(access_token) - - self.repository_path = repository[CONF_PATH] - - repo = self._github_obj.get_repo(self.repository_path) - except self._github.GithubException as err: - _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) - self.setup_error = True - return - - self.name = repository.get(CONF_NAME, repo.name) self.available = False - self.latest_commit_message = None - self.latest_commit_sha = None - self.latest_release_url = None - self.open_issue_count = None - self.latest_open_issue_url = None - self.pull_request_count = None - self.latest_open_pr_url = None - self.stargazers = None - self.forks = None - self.clones = None - self.clones_unique = None - self.views = None - self.views_unique = None + self.repository_response = None + self.commit_response = None + self.issues_response = None + self.pulls_response = None + self.releases_response = None + self.views_response = None + self.clones_response = None - def update(self): - """Update GitHub Sensor.""" + @property + def name(self): + """Return the name of the sensor.""" + return self._repository.get(CONF_NAME, self.repository_response.data.name) + + @property + def last_commit(self): + """Return the last issue.""" + return self.commit_response.data[0] if self.commit_response.data else None + + @property + def last_issue(self): + """Return the last issue.""" + return self.issues_response.data[0] if self.issues_response.data else None + + @property + def last_pull_request(self): + """Return the last pull request.""" + return self.pulls_response.data[0] if self.pulls_response.data else None + + @property + def last_release(self): + """Return the last release.""" + return self.releases_response.data[0] if self.releases_response.data else None + + async def async_update(self): + """Update GitHub data.""" try: - repo = self._github_obj.get_repo(self.repository_path) + await asyncio.gather( + self._update_repository(), + self._update_commit(), + self._update_issues(), + self._update_pulls(), + self._update_releases(), + ) - self.stargazers = repo.stargazers_count - self.forks = repo.forks_count - - open_pull_requests = repo.get_pulls(state="open", sort="created") - if open_pull_requests is not None: - self.pull_request_count = open_pull_requests.totalCount - if open_pull_requests.totalCount > 0: - self.latest_open_pr_url = open_pull_requests[0].html_url - - open_issues = repo.get_issues(state="open", sort="created") - if open_issues is not None: - if self.pull_request_count is None: - self.open_issue_count = open_issues.totalCount - else: - # pull requests are treated as issues too so we need to reduce the received count - self.open_issue_count = ( - open_issues.totalCount - self.pull_request_count - ) - - if open_issues.totalCount > 0: - self.latest_open_issue_url = open_issues[0].html_url - - latest_commit = repo.get_commits()[0] - self.latest_commit_sha = latest_commit.sha - self.latest_commit_message = latest_commit.commit.message - - releases = repo.get_releases() - if releases and releases.totalCount > 0: - self.latest_release_url = releases[0].html_url - - if repo.permissions.push: - clones = repo.get_clones_traffic() - if clones is not None: - self.clones = clones.get("count") - self.clones_unique = clones.get("uniques") - - views = repo.get_views_traffic() - if views is not None: - self.views = views.get("count") - self.views_unique = views.get("uniques") + if self.repository_response.data.permissions.push: + await asyncio.gather( + self._update_clones(), + self._update_views(), + ) self.available = True - except self._github.GithubException as err: + except GitHubException as err: _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) self.available = False + + async def _update_repository(self): + """Update repository data.""" + self.repository_response = await self._github.repos.get(self.repository_path) + + async def _update_commit(self): + """Update commit data.""" + self.commit_response = await self._github.repos.list_commits( + self.repository_path, **{"params": {"per_page": 1}} + ) + + async def _update_issues(self): + """Update issues data.""" + self.issues_response = await self._github.repos.issues.list( + self.repository_path + ) + + async def _update_releases(self): + """Update releases data.""" + self.releases_response = await self._github.repos.releases.list( + self.repository_path + ) + + async def _update_clones(self): + """Update clones data.""" + self.clones_response = await self._github.repos.traffic.clones( + self.repository_path + ) + + async def _update_views(self): + """Update views data.""" + self.views_response = await self._github.repos.traffic.views( + self.repository_path + ) + + async def _update_pulls(self): + """Update pulls data.""" + response = await self._github.repos.pulls.list( + self.repository_path, **{"params": {"per_page": 100}} + ) + if not response.is_last_page: + results = await asyncio.gather( + *( + self._github.repos.pulls.list( + self.repository_path, + **{"params": {"per_page": 100, "page": page_number}}, + ) + for page_number in range( + response.next_page_number, response.last_page_number + 1 + ) + ) + ) + for result in results: + response.data.extend(result.data) + + self.pulls_response = response diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 0b619853348..e63e07d6c85 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -88,7 +88,7 @@ class GitLabSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 20b68b2e5a9..9e13e155f27 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -65,12 +65,12 @@ class GitterSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index b74662db22b..491dd297a05 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -44,154 +44,154 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( key="disk_use_percent", type="fs", name_suffix="used percent", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", ), GlancesSensorEntityDescription( key="disk_use", type="fs", name_suffix="used", - unit_of_measurement=DATA_GIBIBYTES, + native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:harddisk", ), GlancesSensorEntityDescription( key="disk_free", type="fs", name_suffix="free", - unit_of_measurement=DATA_GIBIBYTES, + native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:harddisk", ), GlancesSensorEntityDescription( key="memory_use_percent", type="mem", name_suffix="RAM used percent", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", ), GlancesSensorEntityDescription( key="memory_use", type="mem", name_suffix="RAM used", - unit_of_measurement=DATA_MEBIBYTES, + native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:memory", ), GlancesSensorEntityDescription( key="memory_free", type="mem", name_suffix="RAM free", - unit_of_measurement=DATA_MEBIBYTES, + native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:memory", ), GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", name_suffix="Swap used percent", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", ), GlancesSensorEntityDescription( key="swap_use", type="memswap", name_suffix="Swap used", - unit_of_measurement=DATA_GIBIBYTES, + native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:memory", ), GlancesSensorEntityDescription( key="swap_free", type="memswap", name_suffix="Swap free", - unit_of_measurement=DATA_GIBIBYTES, + native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:memory", ), GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", - unit_of_measurement="15 min", + native_unit_of_measurement="15 min", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", - unit_of_measurement="Count", + native_unit_of_measurement="Count", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", - unit_of_measurement="Count", + native_unit_of_measurement="Count", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", - unit_of_measurement="Count", + native_unit_of_measurement="Count", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", - unit_of_measurement="Count", + native_unit_of_measurement="Count", icon=CPU_ICON, ), GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", name_suffix="CPU used", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=CPU_ICON, ), GlancesSensorEntityDescription( key="temperature_core", type="sensors", name_suffix="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, ), GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", name_suffix="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, ), GlancesSensorEntityDescription( key="fan_speed", type="sensors", name_suffix="Fan speed", - unit_of_measurement="RPM", + native_unit_of_measurement="RPM", icon="mdi:fan", ), GlancesSensorEntityDescription( key="battery", type="sensors", name_suffix="Charge", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:battery", ), GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", - unit_of_measurement="", + native_unit_of_measurement="", icon="mdi:docker", ), GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", name_suffix="Containers CPU used", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:docker", ), GlancesSensorEntityDescription( key="docker_memory_use", type="docker", name_suffix="Containers RAM used", - unit_of_measurement=DATA_MEBIBYTES, + native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:docker", ), ) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index fd31ee37faf..76e2a1c617a 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -83,7 +83,7 @@ class GlancesSensor(SensorEntity): return self.glances_data.available @property - def state(self): + def native_value(self): """Return the state of the resources.""" return self._state diff --git a/homeassistant/components/glances/translations/zh-Hans.json b/homeassistant/components/glances/translations/zh-Hans.json index 22cb2995672..a62b5f8b32e 100644 --- a/homeassistant/components/glances/translations/zh-Hans.json +++ b/homeassistant/components/glances/translations/zh-Hans.json @@ -1,15 +1,35 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u8fde\u63a5" + }, "error": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "wrong_version": "\u4e0d\u652f\u6301\u7684\u7248\u672c (\u4ec5\u96502\u62163)" }, "step": { "user": { "data": { + "host": "\u4e3b\u673a\u5730\u5740", "name": "\u540d\u79f0", "password": "\u5bc6\u7801", - "username": "\u7528\u6237\u540d" - } + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u51ed\u8bc1", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66", + "version": "Glances API \u7248\u672c (2 \u6216 3)" + }, + "title": "\u8bbe\u7f6e Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9891\u7387" + }, + "description": "\u914d\u7f6e Glances \u9009\u9879" } } } diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 308934819cd..04a9d6aaa86 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -43,7 +43,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [DOMAIN_BINARY_SENSOR, DOMAIN_SENSOR, DOMAIN_SWITCH] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Goal Zero Yeti from a config entry.""" name = entry.data[CONF_NAME] host = entry.data[CONF_HOST] @@ -53,8 +53,7 @@ async def async_setup_entry(hass, entry): try: await api.init_connect() except exceptions.ConnectError as ex: - _LOGGER.warning("Failed to connect to device %s", ex) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex async def async_update_data(): """Fetch data from API endpoint.""" @@ -81,7 +80,7 @@ async def async_setup_entry(hass, entry): 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: @@ -94,7 +93,13 @@ class YetiEntity(CoordinatorEntity): _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - def __init__(self, api, coordinator, name, server_unique_id): + def __init__( + self, + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti entity.""" super().__init__(coordinator) self.api = api @@ -104,15 +109,10 @@ class YetiEntity(CoordinatorEntity): @property def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" - model = sw_version = None - if self.api.sysdata: - model = self.api.sysdata[ATTR_MODEL] - if self.api.data: - sw_version = self.api.data["firmwareVersion"] return { ATTR_IDENTIFIERS: {(DOMAIN, self._server_unique_id)}, ATTR_MANUFACTURER: "Goal Zero", ATTR_NAME: self._name, - ATTR_MODEL: str(model), - ATTR_SW_VERSION: str(sw_version), + ATTR_MODEL: self.api.sysdata.get(ATTR_MODEL), + ATTR_SW_VERSION: self.api.data.get("firmwareVersion"), } diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index f9a110eff55..21eecc678ad 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -1,14 +1,51 @@ """Support for Goal Zero Yeti Sensors.""" -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_NAME, CONF_NAME +from __future__ import annotations -from . import YetiEntity -from .const import BINARY_SENSOR_DICT, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_POWER, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN PARALLEL_UPDATES = 0 +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="backlight", + name="Backlight", + icon="mdi:clock-digital", + ), + BinarySensorEntityDescription( + key="app_online", + name="App Online", + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="isCharging", + name="Charging", + device_class=DEVICE_CLASS_BATTERY_CHARGING, + ), + BinarySensorEntityDescription( + key="inputDetected", + name="Input Detected", + device_class=DEVICE_CLASS_POWER, + ), +) -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 Goal Zero Yeti sensor.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] @@ -17,10 +54,10 @@ async def async_setup_entry(hass, entry, async_add_entities): goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], name, - sensor_name, + description, entry.entry_id, ) - for sensor_name in BINARY_SENSOR_DICT + for description in BINARY_SENSOR_TYPES ) @@ -29,26 +66,19 @@ class YetiBinarySensor(YetiEntity, BinarySensorEntity): def __init__( self, - api, - coordinator, - name, - sensor_name, - server_unique_id, - ): + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: BinarySensorEntityDescription, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti sensor.""" super().__init__(api, coordinator, name, server_unique_id) - - self._condition = sensor_name - self._attr_device_class = BINARY_SENSOR_DICT[sensor_name].get(ATTR_DEVICE_CLASS) - self._attr_icon = BINARY_SENSOR_DICT[sensor_name].get(ATTR_ICON) - self._attr_name = f"{name} {BINARY_SENSOR_DICT[sensor_name].get(ATTR_NAME)}" - self._attr_unique_id = ( - f"{server_unique_id}/{BINARY_SENSOR_DICT[sensor_name].get(ATTR_NAME)}" - ) + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{server_unique_id}/{description.key}" @property def is_on(self) -> bool: """Return if the service is on.""" - if self.api.data: - return self.api.data[self._condition] == 1 - return False + return self.api.data.get(self.entity_description.key) == 1 diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index 4c525de9c7d..cc2c4a9874f 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -13,6 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.typing import DiscoveryInfoType from .const import DEFAULT_NAME, DOMAIN @@ -24,11 +25,11 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize a Goal Zero Yeti flow.""" self.ip_address = None - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info[IP_ADDRESS] @@ -36,7 +37,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) self._async_abort_entries_match({CONF_HOST: self.ip_address}) - _, error = await self._async_try_connect(self.ip_address) + _, error = await self._async_try_connect(str(self.ip_address)) if error is None: return await self.async_step_confirm_discovery() return self.async_abort(reason=error) @@ -63,7 +64,9 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} if user_input is not None: @@ -74,7 +77,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): mac_address, error = await self._async_try_connect(host) if error is None: - await self.async_set_unique_id(format_mac(mac_address)) + await self.async_set_unique_id(format_mac(str(mac_address))) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) return self.async_create_entry( title=name, @@ -98,7 +101,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_try_connect(self, host) -> tuple: + 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) diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index e9fed7dc52b..d99cacb253e 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -1,37 +1,6 @@ """Constants for the Goal Zero Yeti integration.""" from datetime import timedelta -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_POWER, -) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - ENERGY_WATT_HOUR, - PERCENTAGE, - POWER_WATT, - SIGNAL_STRENGTH_DECIBELS, - TEMP_CELSIUS, - TIME_MINUTES, - TIME_SECONDS, -) - ATTRIBUTION = "Data provided by Goal Zero" ATTR_DEFAULT_ENABLED = "default_enabled" @@ -41,113 +10,3 @@ DEFAULT_NAME = "Yeti" DATA_KEY_API = "api" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) - -BINARY_SENSOR_DICT = { - "backlight": {ATTR_NAME: "Backlight", ATTR_ICON: "mdi:clock-digital"}, - "app_online": { - ATTR_NAME: "App Online", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY, - }, - "isCharging": { - ATTR_NAME: "Charging", - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, - }, - "inputDetected": { - ATTR_NAME: "Input Detected", - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - }, -} - -SENSOR_DICT = { - "wattsIn": { - ATTR_NAME: "Watts In", - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: True, - }, - "ampsIn": { - ATTR_NAME: "Amps In", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: False, - }, - "wattsOut": { - ATTR_NAME: "Watts Out", - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: True, - }, - "ampsOut": { - ATTR_NAME: "Amps Out", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, - ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: False, - }, - "whOut": { - ATTR_NAME: "WH Out", - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: False, - }, - "whStored": { - ATTR_NAME: "WH Stored", - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_DEFAULT_ENABLED: True, - }, - "volts": { - ATTR_NAME: "Volts", - ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, - ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, - ATTR_DEFAULT_ENABLED: False, - }, - "socPercent": { - ATTR_NAME: "State of Charge Percent", - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_DEFAULT_ENABLED: True, - }, - "timeToEmptyFull": { - ATTR_NAME: "Time to Empty/Full", - ATTR_DEVICE_CLASS: TIME_MINUTES, - ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, - ATTR_DEFAULT_ENABLED: True, - }, - "temperature": { - ATTR_NAME: "Temperature", - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEFAULT_ENABLED: True, - }, - "wifiStrength": { - ATTR_NAME: "Wifi Strength", - ATTR_DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH, - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - ATTR_DEFAULT_ENABLED: True, - }, - "timestamp": { - ATTR_NAME: "Total Run Time", - ATTR_UNIT_OF_MEASUREMENT: TIME_SECONDS, - ATTR_DEFAULT_ENABLED: False, - }, - "ssid": { - ATTR_NAME: "Wi-Fi SSID", - ATTR_DEFAULT_ENABLED: False, - }, - "ipAddr": { - ATTR_NAME: "IP Address", - ATTR_DEFAULT_ENABLED: False, - }, -} - -SWITCH_DICT = { - "v12PortStatus": "12V Port Status", - "usbPortStatus": "USB Port Status", - "acPortStatus": "AC Port Status", -} diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index 52d3a024955..b4a9415d01d 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -8,5 +8,6 @@ {"hostname": "yeti*"} ], "codeowners": ["@tkdrob"], + "quality_scale": "silver", "iot_class": "local_polling" } diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 594e1f0046b..957891e67ed 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -1,25 +1,139 @@ """Support for Goal Zero Yeti Sensors.""" from __future__ import annotations -from homeassistant.components.sensor import ATTR_LAST_RESET, ATTR_STATE_CLASS +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_DEVICE_CLASS, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, + TIME_MINUTES, + TIME_SECONDS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import ( - ATTR_DEFAULT_ENABLED, - DATA_KEY_API, - DATA_KEY_COORDINATOR, - DOMAIN, - SENSOR_DICT, +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="wattsIn", + name="Watts In", + device_class=DEVICE_CLASS_POWER, + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="ampsIn", + name="Amps In", + device_class=DEVICE_CLASS_CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="wattsOut", + name="Watts Out", + device_class=DEVICE_CLASS_POWER, + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="ampsOut", + name="Amps Out", + device_class=DEVICE_CLASS_CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="whOut", + name="WH Out", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="whStored", + name="WH Stored", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="volts", + name="Volts", + device_class=DEVICE_CLASS_VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="socPercent", + name="State of Charge Percent", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="timeToEmptyFull", + name="Time to Empty/Full", + device_class=TIME_MINUTES, + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key="wifiStrength", + name="Wifi Strength", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="timestamp", + name="Total Run Time", + native_unit_of_measurement=TIME_SECONDS, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="ssid", + name="Wi-Fi SSID", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="ipAddr", + name="IP Address", + entity_registry_enabled_default=False, + ), ) -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 Goal Zero Yeti sensor.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] @@ -28,33 +142,32 @@ async def async_setup_entry(hass, entry, async_add_entities): goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], name, - sensor_name, + description, entry.entry_id, ) - for sensor_name in SENSOR_DICT + for description in SENSOR_TYPES ] async_add_entities(sensors, True) -class YetiSensor(YetiEntity): +class YetiSensor(YetiEntity, SensorEntity): """Representation of a Goal Zero Yeti sensor.""" - def __init__(self, api, coordinator, name, sensor_name, server_unique_id): + def __init__( + self, + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: SensorEntityDescription, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti sensor.""" super().__init__(api, coordinator, name, server_unique_id) - self._condition = sensor_name - sensor = SENSOR_DICT[sensor_name] - self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) - self._attr_entity_registry_enabled_default = sensor.get(ATTR_DEFAULT_ENABLED) - self._attr_last_reset = sensor.get(ATTR_LAST_RESET) - self._attr_name = f"{name} {sensor.get(ATTR_NAME)}" - self._attr_state_class = sensor.get(ATTR_STATE_CLASS) - self._attr_unique_id = f"{server_unique_id}/{sensor_name}" - self._attr_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) + self._attr_name = f"{name} {description.name}" + self.entity_description = description + self._attr_unique_id = f"{server_unique_id}/{description.key}" @property - def state(self) -> str | None: + def native_value(self) -> str: """Return the state.""" - if self.api.data: - return self.api.data.get(self._condition) - return None + return self.api.data.get(self.entity_description.key) diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index 9d37bcb0b7b..767c728e62b 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -1,14 +1,35 @@ """Support for Goal Zero Yeti Switches.""" from __future__ import annotations -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN, SWITCH_DICT +from . import Yeti, YetiEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN + +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="v12PortStatus", + name="12V Port Status", + ), + SwitchEntityDescription( + key="usbPortStatus", + name="USB Port Status", + ), + SwitchEntityDescription( + key="acPortStatus", + name="AC Port Status", + ), +) -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 Goal Zero Yeti switch.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] @@ -17,10 +38,10 @@ async def async_setup_entry(hass, entry, async_add_entities): goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], name, - switch_name, + description, entry.entry_id, ) - for switch_name in SWITCH_DICT + for description in SWITCH_TYPES ) @@ -29,33 +50,31 @@ class YetiSwitch(YetiEntity, SwitchEntity): def __init__( self, - api, - coordinator, - name, - switch_name, - server_unique_id, - ): + api: Yeti, + coordinator: DataUpdateCoordinator, + name: str, + description: SwitchEntityDescription, + server_unique_id: str, + ) -> None: """Initialize a Goal Zero Yeti switch.""" super().__init__(api, coordinator, name, server_unique_id) - self._condition = switch_name - self._attr_name = f"{name} {SWITCH_DICT[switch_name]}" - self._attr_unique_id = f"{server_unique_id}/{switch_name}" + self.entity_description = description + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{server_unique_id}/{description.key}" @property def is_on(self) -> bool: """Return state of the switch.""" - if self.api.data: - return self.api.data[self._condition] - return False + return self.api.data.get(self.entity_description.key) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn off the switch.""" - payload = {self._condition: 0} + payload = {self.entity_description.key: 0} await self.api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn on the switch.""" - payload = {self._condition: 1} + payload = {self.entity_description.key: 1} await self.api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) diff --git a/homeassistant/components/goalzero/translations/es-419.json b/homeassistant/components/goalzero/translations/es-419.json new file mode 100644 index 00000000000..9d464996349 --- /dev/null +++ b/homeassistant/components/goalzero/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "confirm_discovery": { + "description": "Se recomienda reservar DHCP en su enrutador. Si no se configura, es posible que el dispositivo no est\u00e9 disponible hasta que Home Assistant detecte la nueva direcci\u00f3n IP. Consulte el manual de usuario de su enrutador." + }, + "user": { + "description": "Primero, debe descargar la aplicaci\u00f3n Goal Zero: https://www.goalzero.com/product-features/yeti-app/ \n\nSiga las instrucciones para conectar su Yeti a su red Wi-Fi. Se recomienda reservar DHCP en su enrutador. Si no se configura, es posible que el dispositivo no est\u00e9 disponible hasta que Home Assistant detecte la nueva direcci\u00f3n IP. Consulte el manual de usuario de su enrutador." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index 99edc855733..a9be18d06a6 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -72,7 +72,7 @@ class DoorSensorBattery(GoGoGate2Entity, SensorEntity): return DEVICE_CLASS_BATTERY @property - def state(self): + def native_value(self): """Return the state of the entity.""" door = self._get_door() return door.voltage # This is a percentage, not an absolute voltage @@ -110,13 +110,13 @@ class DoorSensorTemperature(GoGoGate2Entity, SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def state(self): + def native_value(self): """Return the state of the entity.""" door = self._get_door() return door.temperature @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement.""" return TEMP_CELSIUS diff --git a/homeassistant/components/gogogate2/translations/hu.json b/homeassistant/components/gogogate2/translations/hu.json index 641046d7745..30d6ef5c016 100644 --- a/homeassistant/components/gogogate2/translations/hu.json +++ b/homeassistant/components/gogogate2/translations/hu.json @@ -15,6 +15,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Adja meg a sz\u00fcks\u00e9ges inform\u00e1ci\u00f3kat al\u00e1bb.", "title": "A GogoGate2 vagy az iSmartGate be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 6cc7221ba1d..7e157d238d5 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,5 +1,5 @@ """Support for Google - Calendar Event Devices.""" -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from enum import Enum import logging import os @@ -27,8 +27,8 @@ from homeassistant.const import ( from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.event import track_time_change -from homeassistant.util import convert, dt +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import convert _LOGGER = logging.getLogger(__name__) @@ -108,16 +108,19 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -_SINGLE_CALSEARCH_CONFIG = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, - vol.Optional(CONF_OFFSET): cv.string, - vol.Optional(CONF_SEARCH): cv.string, - vol.Optional(CONF_TRACK): cv.boolean, - vol.Optional(CONF_MAX_RESULTS): cv.positive_int, - } +_SINGLE_CALSEARCH_CONFIG = vol.All( + cv.deprecated(CONF_MAX_RESULTS), + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, + vol.Optional(CONF_OFFSET): cv.string, + vol.Optional(CONF_SEARCH): cv.string, + vol.Optional(CONF_TRACK): cv.boolean, + vol.Optional(CONF_MAX_RESULTS): cv.positive_int, # Now unused + } + ), ) DEVICE_SCHEMA = vol.Schema( @@ -185,7 +188,12 @@ def do_authentication(hass, hass_config, config): def step2_exchange(now): """Keep trying to validate the user_code until it expires.""" - if now >= dt.as_local(dev_flow.user_code_expiry): + + # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime + # object without tzinfo. For the comparison below to work, it needs one. + user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc) + + if now >= user_code_expiry: hass.components.persistent_notification.create( "Authentication code expired, please restart " "Home-Assistant and try again", @@ -213,7 +221,7 @@ def do_authentication(hass, hass_config, config): notification_id=NOTIFICATION_ID, ) - listener = track_time_change( + listener = track_utc_time_change( hass, step2_exchange, second=range(0, 60, dev_flow.interval) ) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 2cc66121948..5c06e0fbb94 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -18,7 +18,6 @@ from homeassistant.util import Throttle, dt from . import ( CONF_CAL_ID, CONF_IGNORE_AVAILABILITY, - CONF_MAX_RESULTS, CONF_SEARCH, CONF_TRACK, DEFAULT_CONF_OFFSET, @@ -30,7 +29,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_GOOGLE_SEARCH_PARAMS = { "orderBy": "startTime", - "maxResults": 5, "singleEvents": True, } @@ -71,7 +69,6 @@ class GoogleCalendarEventDevice(CalendarEventDevice): calendar, data.get(CONF_SEARCH), data.get(CONF_IGNORE_AVAILABILITY), - data.get(CONF_MAX_RESULTS), ) self._event = None self._name = data[CONF_NAME] @@ -113,15 +110,12 @@ class GoogleCalendarEventDevice(CalendarEventDevice): class GoogleCalendarData: """Class to utilize calendar service object to get next event.""" - def __init__( - self, calendar_service, calendar_id, search, ignore_availability, max_results - ): + def __init__(self, calendar_service, calendar_id, search, ignore_availability): """Set up how we are going to search the google calendar.""" self.calendar_service = calendar_service self.calendar_id = calendar_id self.search = search self.ignore_availability = ignore_availability - self.max_results = max_results self.event = None def _prepare_query(self): @@ -132,8 +126,8 @@ class GoogleCalendarData: return None, None params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) params["calendarId"] = self.calendar_id - if self.max_results: - params["maxResults"] = self.max_results + params["maxResults"] = 100 # Page size + if self.search: params["q"] = self.search @@ -147,18 +141,30 @@ class GoogleCalendarData: params["timeMin"] = start_date.isoformat("T") params["timeMax"] = end_date.isoformat("T") + event_list = [] events = await hass.async_add_executor_job(service.events) + page_token = None + while True: + page_token = await self.async_get_events_page( + hass, events, params, page_token, event_list + ) + if not page_token: + break + return event_list + + async def async_get_events_page(self, hass, events, params, page_token, event_list): + """Get a page of events in a specific time frame.""" + params["pageToken"] = page_token result = await hass.async_add_executor_job(events.list(**params).execute) items = result.get("items", []) - event_list = [] for item in items: if not self.ignore_availability and "transparency" in item: if item["transparency"] == "opaque": event_list.append(item) else: event_list.append(item) - return event_list + return result.get("nextPageToken") @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 13516783233..1e0c0a06114 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -2,14 +2,13 @@ from __future__ import annotations import logging -from typing import Any import voluptuous as vol -# Typing imports from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ALIASES, @@ -91,7 +90,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, yaml_config: dict[str, Any]): +async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate Google Actions component.""" if DOMAIN not in yaml_config: return True diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 2e43e20f124..d23560b85c1 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -133,6 +133,7 @@ DOMAIN_TO_GOOGLE_TYPES = { media_player.DOMAIN: TYPE_SETTOP, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, + sensor.DOMAIN: TYPE_SENSOR, select.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 36222902296..d1ed328703e 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -28,6 +28,7 @@ from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING from homeassistant.components.media_player.const import MEDIA_TYPE_CHANNEL from homeassistant.const import ( ATTR_ASSUMED_STATE, + ATTR_BATTERY_LEVEL, ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -74,6 +75,7 @@ from .const import ( ERR_ALREADY_DISARMED, ERR_ALREADY_STOPPED, ERR_CHALLENGE_NOT_SETUP, + ERR_FUNCTION_NOT_SUPPORTED, ERR_NO_AVAILABLE_CHANNEL, ERR_NOT_SUPPORTED, ERR_UNSUPPORTED_INPUT, @@ -104,6 +106,9 @@ TRAIT_HUMIDITY_SETTING = f"{PREFIX_TRAITS}HumiditySetting" TRAIT_TRANSPORT_CONTROL = f"{PREFIX_TRAITS}TransportControl" TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState" TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel" +TRAIT_LOCATOR = f"{PREFIX_TRAITS}Locator" +TRAIT_ENERGYSTORAGE = f"{PREFIX_TRAITS}EnergyStorage" +TRAIT_SENSOR_STATE = f"{PREFIX_TRAITS}SensorState" PREFIX_COMMANDS = "action.devices.commands." COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" @@ -145,6 +150,8 @@ COMMAND_MEDIA_STOP = f"{PREFIX_COMMANDS}mediaStop" COMMAND_REVERSE = f"{PREFIX_COMMANDS}Reverse" COMMAND_SET_HUMIDITY = f"{PREFIX_COMMANDS}SetHumidity" COMMAND_SELECT_CHANNEL = f"{PREFIX_COMMANDS}selectChannel" +COMMAND_LOCATE = f"{PREFIX_COMMANDS}Locate" +COMMAND_CHARGE = f"{PREFIX_COMMANDS}Charge" TRAITS = [] @@ -406,10 +413,11 @@ class ColorSettingTrait(_Trait): def query_attributes(self): """Return color temperature query attributes.""" - color_modes = self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) + color_mode = self.state.attributes.get(light.ATTR_COLOR_MODE) + color = {} - if light.color_supported(color_modes): + if light.color_supported([color_mode]): color_hs = self.state.attributes.get(light.ATTR_HS_COLOR) brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS, 1) if color_hs is not None: @@ -419,7 +427,7 @@ class ColorSettingTrait(_Trait): "value": brightness / 255, } - if light.color_temp_supported(color_modes): + if light.color_temp_supported([color_mode]): temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) # Some faulty integrations might put 0 in here, raising exception. if temp == 0: @@ -566,6 +574,98 @@ class DockTrait(_Trait): ) +@register_trait +class LocatorTrait(_Trait): + """Trait to offer locate functionality. + + https://developers.google.com/actions/smarthome/traits/locator + """ + + name = TRAIT_LOCATOR + commands = [COMMAND_LOCATE] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_LOCATE + + def sync_attributes(self): + """Return locator attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return locator query attributes.""" + return {} + + async def execute(self, command, data, params, challenge): + """Execute a locate command.""" + if params.get("silence", False): + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, + "Silencing a Locate request is not yet supported", + ) + + await self.hass.services.async_call( + self.state.domain, + vacuum.SERVICE_LOCATE, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + + +class EnergyStorageTrait(_Trait): + """Trait to offer EnergyStorage functionality. + + https://developers.google.com/actions/smarthome/traits/energystorage + """ + + name = TRAIT_ENERGYSTORAGE + commands = [COMMAND_CHARGE] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_BATTERY + + def sync_attributes(self): + """Return EnergyStorage attributes for a sync request.""" + return { + "isRechargeable": True, + "queryOnlyEnergyStorage": True, + } + + def query_attributes(self): + """Return EnergyStorage query attributes.""" + battery_level = self.state.attributes.get(ATTR_BATTERY_LEVEL) + if battery_level == 100: + descriptive_capacity_remaining = "FULL" + elif 75 <= battery_level < 100: + descriptive_capacity_remaining = "HIGH" + elif 50 <= battery_level < 75: + descriptive_capacity_remaining = "MEDIUM" + elif 25 <= battery_level < 50: + descriptive_capacity_remaining = "LOW" + elif 0 <= battery_level < 25: + descriptive_capacity_remaining = "CRITICALLY_LOW" + return { + "descriptiveCapacityRemaining": descriptive_capacity_remaining, + "capacityRemaining": [{"rawValue": battery_level, "unit": "PERCENTAGE"}], + "capacityUntilFull": [ + {"rawValue": 100 - battery_level, "unit": "PERCENTAGE"} + ], + "isCharging": self.state.state == vacuum.STATE_DOCKED, + "isPluggedIn": self.state.state == vacuum.STATE_DOCKED, + } + + async def execute(self, command, data, params, challenge): + """Execute a dock command.""" + raise SmartHomeError( + ERR_FUNCTION_NOT_SUPPORTED, + "Controlling charging of a vacuum is not yet supported", + ) + + @register_trait class StartStopTrait(_Trait): """Trait to offer StartStop functionality. @@ -1290,7 +1390,7 @@ class FanSpeedTrait(_Trait): ) elif domain == climate.DOMAIN: - modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) + modes = self.state.attributes.get(climate.ATTR_FAN_MODES) or [] for mode in modes: speed = { "speed_name": mode, @@ -2187,3 +2287,61 @@ class ChannelTrait(_Trait): blocking=True, context=data.context, ) + + +@register_trait +class SensorStateTrait(_Trait): + """Trait to get sensor state. + + https://developers.google.com/actions/smarthome/traits/sensorstate + """ + + sensor_types = { + sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), + sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + name = TRAIT_SENSOR_STATE + commands = [] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + return domain == sensor.DOMAIN and device_class in ( + sensor.DEVICE_CLASS_AQI, + sensor.DEVICE_CLASS_CO, + sensor.DEVICE_CLASS_CO2, + sensor.DEVICE_CLASS_PM25, + sensor.DEVICE_CLASS_PM10, + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ) + + def sync_attributes(self): + """Return attributes for a sync request.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + data = self.sensor_types.get(device_class) + if data is not None: + return { + "sensorStatesSupported": { + "name": data[0], + "numericCapabilities": {"rawValueUnit": data[1]}, + } + } + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) + data = self.sensor_types.get(device_class) + if data is not None: + return { + "currentSensorStateData": [ + {"name": data[0], "rawValue": self.state.state} + ] + } diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index b6bd6f71bf4..1a0396a69ac 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -1,4 +1,6 @@ """Support for Google Maps location sharing.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,10 @@ from locationsharinglib import Service from locationsharinglib.locationsharinglibexceptions import InvalidCookies import voluptuous as vol -from homeassistant.components.device_tracker import PLATFORM_SCHEMA, SOURCE_TYPE_GPS +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA as PLATFORM_SCHEMA_BASE, + SOURCE_TYPE_GPS, +) from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, @@ -30,7 +35,9 @@ CONF_MAX_GPS_ACCURACY = "max_gps_accuracy" CREDENTIALS_FILE = ".google_maps_location_sharing.cookies" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +# the parent "device_tracker" have marked the schemas as legacy, so this +# need to be refactored as part of a bigger rewrite. +PLATFORM_SCHEMA = PLATFORM_SCHEMA_BASE.extend( { vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_MAX_GPS_ACCURACY, default=100000): vol.Coerce(float), @@ -53,7 +60,7 @@ class GoogleMapsScanner: self.username = config[CONF_USERNAME] self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60) - self._prev_seen = {} + self._prev_seen: dict[str, str] = {} credfile = f"{hass.config.path(CREDENTIALS_FILE)}.{slugify(self.username)}" try: diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index 514b919e877..1de7e98d776 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -5,7 +5,6 @@ import datetime import json import logging import os -from typing import Any from google.cloud import pubsub_v1 import voluptuous as vol @@ -14,6 +13,7 @@ from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UN from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -39,15 +39,12 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, yaml_config: dict[str, Any]): +def setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate Google Pub/Sub component.""" - config = yaml_config[DOMAIN] project_id = config[CONF_PROJECT_ID] topic_name = config[CONF_TOPIC_NAME] - service_principal_path = os.path.join( - hass.config.config_dir, config[CONF_SERVICE_PRINCIPAL] - ) + service_principal_path = hass.config.path(config[CONF_SERVICE_PRINCIPAL]) if not os.path.isfile(service_principal_path): _LOGGER.error("Path to credentials file cannot be found") diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 6dbe6aa698b..c8cb9d54510 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -196,7 +196,7 @@ class GoogleTravelTimeSensor(SensorEntity): await self.first_update() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._matrix is None: return None @@ -250,7 +250,7 @@ class GoogleTravelTimeSensor(SensorEntity): return res @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 28ec5df7486..46cad2afe08 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -1,11 +1,18 @@ """Support for retrieving status info from Google Wifi/OnHub routers.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -32,25 +39,70 @@ ENDPOINT = "/api/v1/status" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) -MONITORED_CONDITIONS = { - ATTR_CURRENT_VERSION: [ - ["software", "softwareVersion"], - None, - "mdi:checkbox-marked-circle-outline", - ], - ATTR_NEW_VERSION: [["software", "updateNewVersion"], None, "mdi:update"], - ATTR_UPTIME: [["system", "uptime"], TIME_DAYS, "mdi:timelapse"], - ATTR_LAST_RESTART: [["system", "uptime"], None, "mdi:restart"], - ATTR_LOCAL_IP: [["wan", "localIpAddress"], None, "mdi:access-point-network"], - ATTR_STATUS: [["wan", "online"], None, "mdi:google"], -} + +@dataclass +class GoogleWifiRequiredKeysMixin: + """Mixin for required keys.""" + + primary_key: str + sensor_key: str + + +@dataclass +class GoogleWifiSensorEntityDescription( + SensorEntityDescription, GoogleWifiRequiredKeysMixin +): + """Describes GoogleWifi sensor entity.""" + + +SENSOR_TYPES: tuple[GoogleWifiSensorEntityDescription, ...] = ( + GoogleWifiSensorEntityDescription( + key=ATTR_CURRENT_VERSION, + primary_key="software", + sensor_key="softwareVersion", + icon="mdi:checkbox-marked-circle-outline", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_NEW_VERSION, + primary_key="software", + sensor_key="updateNewVersion", + icon="mdi:update", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_UPTIME, + primary_key="system", + sensor_key="uptime", + native_unit_of_measurement=TIME_DAYS, + icon="mdi:timelapse", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_LAST_RESTART, + primary_key="system", + sensor_key="uptime", + icon="mdi:restart", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_LOCAL_IP, + primary_key="wan", + sensor_key="localIpAddress", + icon="mdi:access-point-network", + ), + GoogleWifiSensorEntityDescription( + key=ATTR_STATUS, + primary_key="wan", + sensor_key="online", + icon="mdi:google", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST, default=DEFAULT_HOST): 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)] + ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } ) @@ -58,64 +110,42 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Google Wifi sensor.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - conditions = config.get(CONF_MONITORED_CONDITIONS) + name = config[CONF_NAME] + host = config[CONF_HOST] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] - api = GoogleWifiAPI(host, conditions) - dev = [] - for condition in conditions: - dev.append(GoogleWifiSensor(api, name, condition)) - - add_entities(dev, True) + api = GoogleWifiAPI(host, monitored_conditions) + entities = [ + GoogleWifiSensor(api, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + add_entities(entities, True) class GoogleWifiSensor(SensorEntity): """Representation of a Google Wifi sensor.""" - def __init__(self, api, name, variable): + entity_description: GoogleWifiSensorEntityDescription + + def __init__(self, api, name, description: GoogleWifiSensorEntityDescription): """Initialize a Google Wifi sensor.""" + self.entity_description = description self._api = api - self._name = name - self._state = None - - variable_info = MONITORED_CONDITIONS[variable] - self._var_name = variable - self._var_units = variable_info[1] - self._var_icon = variable_info[2] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name}_{self._var_name}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._var_icon - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._var_units + self._attr_name = f"{name}_{description.key}" @property def available(self): """Return availability of Google Wifi API.""" return self._api.available - @property - def state(self): - """Return the state of the device.""" - return self._state - def update(self): """Get the latest data from the Google Wifi API.""" self._api.update() if self.available: - self._state = self._api.data[self._var_name] + self._attr_native_value = self._api.data[self.entity_description.key] else: - self._state = None + self._attr_native_value = None class GoogleWifiAPI: @@ -155,13 +185,15 @@ class GoogleWifiAPI: def data_format(self): """Format raw data into easily accessible dict.""" - for attr_key in self.conditions: - value = MONITORED_CONDITIONS[attr_key] + for description in SENSOR_TYPES: + if description.key not in self.conditions: + continue + attr_key = description.key try: - primary_key = value[0][0] - sensor_key = value[0][1] - if primary_key in self.raw_data: - sensor_value = self.raw_data[primary_key][sensor_key] + if description.primary_key in self.raw_data: + sensor_value = self.raw_data[description.primary_key][ + description.sensor_key + ] # Format sensor for better readability if attr_key == ATTR_NEW_VERSION and sensor_value == "0.0.0.0": sensor_value = "Latest" @@ -185,7 +217,7 @@ class GoogleWifiAPI: _LOGGER.error( "Router does not support %s field. " "Please remove %s from monitored_conditions", - sensor_key, + description.sensor_key, attr_key, ) self.data[attr_key] = STATE_UNKNOWN diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 2f97f62337c..1b502827996 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -84,7 +84,7 @@ class GpsdSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: return "3D Fix" diff --git a/homeassistant/components/gree/translations/zh-Hans.json b/homeassistant/components/gree/translations/zh-Hans.json new file mode 100644 index 00000000000..808f01b57a8 --- /dev/null +++ b/homeassistant/components/gree/translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u6b64\u7f51\u7edc\u672a\u53d1\u73b0\u76f8\u5173\u8bbe\u5907", + "single_instance_allowed": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e\u3002\u53ea\u5141\u8bb8\u5b58\u5728\u4e00\u4e2a\u914d\u7f6e\u6587\u6863" + }, + "step": { + "confirm": { + "description": "\u4f60\u60f3\u8981\u5f00\u59cb\u914d\u7f6e\u5417\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index fac11395c8b..7fbfa717229 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -147,7 +147,7 @@ class CurrentSensor(GEMSensor): """Entity showing power usage on one channel of the monitor.""" _attr_icon = CURRENT_SENSOR_ICON - _attr_unit_of_measurement = UNIT_WATTS + _attr_native_unit_of_measurement = UNIT_WATTS def __init__(self, monitor_serial_number, number, name, net_metering): """Construct the entity.""" @@ -158,7 +158,7 @@ class CurrentSensor(GEMSensor): return monitor.channels[self._number - 1] @property - def state(self): + def native_value(self): """Return the current number of watts being used by the channel.""" if not self._sensor: return None @@ -203,7 +203,7 @@ class PulseCounter(GEMSensor): return monitor.pulse_counters[self._number - 1] @property - def state(self): + def native_value(self): """Return the current rate of change for the given pulse counter.""" if not self._sensor or self._sensor.pulses_per_second is None: return None @@ -225,7 +225,7 @@ class PulseCounter(GEMSensor): return 3600 @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement for this pulse counter.""" return f"{self._counted_quantity}/{self._time_unit}" @@ -253,7 +253,7 @@ class TemperatureSensor(GEMSensor): return monitor.temperature_sensors[self._number - 1] @property - def state(self): + def native_value(self): """Return the current temperature being reported by this sensor.""" if not self._sensor: return None @@ -261,7 +261,7 @@ class TemperatureSensor(GEMSensor): return self._sensor.temperature @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement for this sensor (user specified).""" return self._unit @@ -270,7 +270,7 @@ class VoltageSensor(GEMSensor): """Entity showing voltage.""" _attr_icon = VOLTAGE_ICON - _attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT + _attr_native_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT def __init__(self, monitor_serial_number, number, name): """Construct the entity.""" @@ -281,7 +281,7 @@ class VoltageSensor(GEMSensor): return monitor @property - def state(self): + def native_value(self): """Return the current voltage being reported by this sensor.""" if not self._sensor: return None diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 397c7e609f3..3870ad3cca5 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -48,6 +48,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType from . import GroupEntity +from .util import attribute_equal, reduce_attribute KEY_OPEN_CLOSE = "open_close" KEY_STOP = "stop" @@ -266,49 +267,33 @@ class CoverGroup(GroupEntity, CoverEntity): continue if state.state == STATE_OPEN: self._attr_is_closed = False - break + continue if state.state == STATE_CLOSING: self._attr_is_closing = True - break + continue if state.state == STATE_OPENING: self._attr_is_opening = True - break + continue - self._attr_current_cover_position = None - if self._covers[KEY_POSITION]: - position: int | None = -1 - self._attr_current_cover_position = 0 if self.is_closed else 100 - for entity_id in self._covers[KEY_POSITION]: - state = self.hass.states.get(entity_id) - if state is None: - continue - pos = state.attributes.get(ATTR_CURRENT_POSITION) - if position == -1: - position = pos - elif position != pos: - self._attr_assumed_state = True - break - else: - if position != -1: - self._attr_current_cover_position = position + position_covers = self._covers[KEY_POSITION] + all_position_states = [self.hass.states.get(x) for x in position_covers] + position_states: list[State] = list(filter(None, all_position_states)) + self._attr_current_cover_position = reduce_attribute( + position_states, ATTR_CURRENT_POSITION + ) + self._attr_assumed_state |= not attribute_equal( + position_states, ATTR_CURRENT_POSITION + ) - self._attr_current_cover_tilt_position = None - if self._tilts[KEY_POSITION]: - position = -1 - self._attr_current_cover_tilt_position = 100 - for entity_id in self._tilts[KEY_POSITION]: - state = self.hass.states.get(entity_id) - if state is None: - continue - pos = state.attributes.get(ATTR_CURRENT_TILT_POSITION) - if position == -1: - position = pos - elif position != pos: - self._attr_assumed_state = True - break - else: - if position != -1: - self._attr_current_cover_tilt_position = position + tilt_covers = self._tilts[KEY_POSITION] + all_tilt_states = [self.hass.states.get(x) for x in tilt_covers] + tilt_states: list[State] = list(filter(None, all_tilt_states)) + self._attr_current_cover_tilt_position = reduce_attribute( + tilt_states, ATTR_CURRENT_TILT_POSITION + ) + self._attr_assumed_state |= not attribute_equal( + tilt_states, ATTR_CURRENT_TILT_POSITION + ) supported_features = 0 supported_features |= ( diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index bb0762d2278..a3a02ee6b9c 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -2,9 +2,8 @@ from __future__ import annotations from collections import Counter -from collections.abc import Iterator import itertools -from typing import Any, Callable, Set, cast +from typing import Any, Set, cast import voluptuous as vol @@ -24,6 +23,7 @@ from homeassistant.components.light import ( ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, COLOR_MODE_BRIGHTNESS, @@ -50,6 +50,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType from . import GroupEntity +from .util import find_state_attributes, mean_tuple, reduce_attribute DEFAULT_NAME = "Light Group" @@ -82,6 +83,24 @@ async def async_setup_platform( ) +FORWARDED_ATTRIBUTES = frozenset( + { + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_TRANSITION, + ATTR_WHITE, + ATTR_WHITE_VALUE, + ATTR_XY_COLOR, + } +) + + class LightGroup(GroupEntity, light.LightEntity): """Representation of a light group.""" @@ -128,40 +147,10 @@ class LightGroup(GroupEntity, light.LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Forward the turn_on command to all lights in the light group.""" - data = {ATTR_ENTITY_ID: self._entity_ids} - - if ATTR_BRIGHTNESS in kwargs: - data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] - - if ATTR_HS_COLOR in kwargs: - data[ATTR_HS_COLOR] = kwargs[ATTR_HS_COLOR] - - if ATTR_RGB_COLOR in kwargs: - data[ATTR_RGB_COLOR] = kwargs[ATTR_RGB_COLOR] - - if ATTR_RGBW_COLOR in kwargs: - data[ATTR_RGBW_COLOR] = kwargs[ATTR_RGBW_COLOR] - - if ATTR_RGBWW_COLOR in kwargs: - data[ATTR_RGBWW_COLOR] = kwargs[ATTR_RGBWW_COLOR] - - if ATTR_XY_COLOR in kwargs: - data[ATTR_XY_COLOR] = kwargs[ATTR_XY_COLOR] - - if ATTR_COLOR_TEMP in kwargs: - data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP] - - if ATTR_WHITE_VALUE in kwargs: - data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE] - - if ATTR_EFFECT in kwargs: - data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] - - if ATTR_TRANSITION in kwargs: - data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] - - if ATTR_FLASH in kwargs: - data[ATTR_FLASH] = kwargs[ATTR_FLASH] + data = { + key: value for key, value in kwargs.items() if key in FORWARDED_ATTRIBUTES + } + data[ATTR_ENTITY_ID] = self._entity_ids await self.hass.services.async_call( light.DOMAIN, @@ -194,36 +183,36 @@ class LightGroup(GroupEntity, light.LightEntity): self._attr_is_on = len(on_states) > 0 self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) - self._attr_brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS) + self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) - self._attr_hs_color = _reduce_attribute( - on_states, ATTR_HS_COLOR, reduce=_mean_tuple + self._attr_hs_color = reduce_attribute( + on_states, ATTR_HS_COLOR, reduce=mean_tuple ) - self._attr_rgb_color = _reduce_attribute( - on_states, ATTR_RGB_COLOR, reduce=_mean_tuple + self._attr_rgb_color = reduce_attribute( + on_states, ATTR_RGB_COLOR, reduce=mean_tuple ) - self._attr_rgbw_color = _reduce_attribute( - on_states, ATTR_RGBW_COLOR, reduce=_mean_tuple + self._attr_rgbw_color = reduce_attribute( + on_states, ATTR_RGBW_COLOR, reduce=mean_tuple ) - self._attr_rgbww_color = _reduce_attribute( - on_states, ATTR_RGBWW_COLOR, reduce=_mean_tuple + self._attr_rgbww_color = reduce_attribute( + on_states, ATTR_RGBWW_COLOR, reduce=mean_tuple ) - self._attr_xy_color = _reduce_attribute( - on_states, ATTR_XY_COLOR, reduce=_mean_tuple + self._attr_xy_color = reduce_attribute( + on_states, ATTR_XY_COLOR, reduce=mean_tuple ) - self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE) + self._white_value = reduce_attribute(on_states, ATTR_WHITE_VALUE) - self._attr_color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP) - self._attr_min_mireds = _reduce_attribute( + self._attr_color_temp = reduce_attribute(on_states, ATTR_COLOR_TEMP) + self._attr_min_mireds = reduce_attribute( states, ATTR_MIN_MIREDS, default=154, reduce=min ) - self._attr_max_mireds = _reduce_attribute( + self._attr_max_mireds = reduce_attribute( states, ATTR_MAX_MIREDS, default=500, reduce=max ) self._attr_effect_list = None - all_effect_lists = list(_find_state_attributes(states, ATTR_EFFECT_LIST)) + all_effect_lists = list(find_state_attributes(states, ATTR_EFFECT_LIST)) if all_effect_lists: # Merge all effects from all effect_lists with a union merge. self._attr_effect_list = list(set().union(*all_effect_lists)) @@ -233,14 +222,14 @@ class LightGroup(GroupEntity, light.LightEntity): self._attr_effect_list.insert(0, "None") self._attr_effect = None - all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT)) + all_effects = list(find_state_attributes(on_states, ATTR_EFFECT)) if all_effects: # Report the most common effect. effects_count = Counter(itertools.chain(all_effects)) self._attr_effect = effects_count.most_common(1)[0][0] self._attr_color_mode = None - all_color_modes = list(_find_state_attributes(on_states, ATTR_COLOR_MODE)) + all_color_modes = list(find_state_attributes(on_states, ATTR_COLOR_MODE)) if all_color_modes: # Report the most common color mode, select brightness and onoff last color_mode_count = Counter(itertools.chain(all_color_modes)) @@ -252,7 +241,7 @@ class LightGroup(GroupEntity, light.LightEntity): self._attr_supported_color_modes = None all_supported_color_modes = list( - _find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) + find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) ) if all_supported_color_modes: # Merge all color modes. @@ -261,49 +250,10 @@ class LightGroup(GroupEntity, light.LightEntity): ) self._attr_supported_features = 0 - for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + for support in find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature # we find. self._attr_supported_features |= support # Bitwise-and the supported features with the GroupedLight's features # so that we don't break in the future when a new feature is added. self._attr_supported_features &= SUPPORT_GROUP_LIGHT - - -def _find_state_attributes(states: list[State], key: str) -> Iterator[Any]: - """Find attributes with matching key from states.""" - for state in states: - value = state.attributes.get(key) - if value is not None: - yield value - - -def _mean_int(*args: Any) -> int: - """Return the mean of the supplied values.""" - return int(sum(args) / len(args)) - - -def _mean_tuple(*args: Any) -> tuple[float | Any, ...]: - """Return the mean values along the columns of the supplied values.""" - return tuple(sum(x) / len(x) for x in zip(*args)) - - -def _reduce_attribute( - states: list[State], - key: str, - default: Any | None = None, - reduce: Callable[..., Any] = _mean_int, -) -> Any: - """Find the first attribute matching key from states. - - If none are found, return default. - """ - attrs = list(_find_state_attributes(states, key)) - - if not attrs: - return default - - if len(attrs) == 1: - return attrs[0] - - return reduce(*attrs) diff --git a/homeassistant/components/group/util.py b/homeassistant/components/group/util.py new file mode 100644 index 00000000000..7e284691049 --- /dev/null +++ b/homeassistant/components/group/util.py @@ -0,0 +1,57 @@ +"""Utility functions to combine state attributes from multiple entities.""" +from __future__ import annotations + +from collections.abc import Iterator +from itertools import groupby +from typing import Any, Callable + +from homeassistant.core import State + + +def find_state_attributes(states: list[State], key: str) -> Iterator[Any]: + """Find attributes with matching key from states.""" + for state in states: + value = state.attributes.get(key) + if value is not None: + yield value + + +def mean_int(*args: Any) -> int: + """Return the mean of the supplied values.""" + return int(sum(args) / len(args)) + + +def mean_tuple(*args: Any) -> tuple[float | Any, ...]: + """Return the mean values along the columns of the supplied values.""" + return tuple(sum(x) / len(x) for x in zip(*args)) + + +def attribute_equal(states: list[State], key: str) -> bool: + """Return True if all attributes found matching key from states are equal. + + Note: Returns True if no matching attribute is found. + """ + attrs = find_state_attributes(states, key) + grp = groupby(attrs) + return bool(next(grp, True) and not next(grp, False)) + + +def reduce_attribute( + states: list[State], + key: str, + default: Any | None = None, + reduce: Callable[..., Any] = mean_int, +) -> Any: + """Find the first attribute matching key from states. + + If none are found, return default. + """ + attrs = list(find_state_attributes(states, key)) + + if not attrs: + return default + + if len(attrs) == 1: + return attrs[0] + + return reduce(*attrs) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index 45f56a327b2..d6b2c7db9fe 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -76,7 +76,3 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() self.data.update(user_input) return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) - - async def async_step_import(self, import_data): - """Migrate old yaml config to config flow.""" - return await self.async_step_user(import_data) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 0b11e9994ca..e0297de5eff 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -7,7 +7,7 @@ DEFAULT_NAME = "Growatt" SERVER_URLS = [ "https://server.growatt.com/", - "https://server-us.growatt.com", + "https://server-us.growatt.com/", "http://server.smten.com/", ] diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 8b4a82d7b99..ab2d07c147b 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server/", "requirements": ["growattServer==1.0.1"], - "codeowners": ["@indykoning", "@muppet3000"], + "codeowners": ["@indykoning", "@muppet3000", "@JasperPlant"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index fe6bdeb70e8..03da4fe4b57 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -1,14 +1,19 @@ """Read status of growatt inverters.""" +from __future__ import annotations + +from dataclasses import dataclass import datetime import json import logging import re import growattServer -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.components.sensor import ( + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -31,547 +36,820 @@ from homeassistant.const import ( POWER_WATT, TEMP_CELSIUS, ) -import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle, dt -from .const import CONF_PLANT_ID, DEFAULT_NAME, DEFAULT_PLANT_ID, DEFAULT_URL, DOMAIN +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DEFAULT_URL _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=1) -# Sensor type order is: Sensor name, Unit of measurement, api data name, additional options -TOTAL_SENSOR_TYPES = { - "total_money_today": ("Total money today", CURRENCY_EURO, "plantMoneyText", {}), - "total_money_total": ("Money lifetime", CURRENCY_EURO, "totalMoneyText", {}), - "total_energy_today": ( - "Energy Today", - ENERGY_KILO_WATT_HOUR, - "todayEnergy", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "total_output_power": ( - "Output Power", - POWER_WATT, - "invTodayPpv", - {"device_class": DEVICE_CLASS_POWER}, - ), - "total_energy_output": ( - "Lifetime energy output", - ENERGY_KILO_WATT_HOUR, - "totalEnergy", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "total_maximum_output": ( - "Maximum power", - POWER_WATT, - "nominalPower", - {"device_class": DEVICE_CLASS_POWER}, - ), -} -INVERTER_SENSOR_TYPES = { - "inverter_energy_today": ( - "Energy today", - ENERGY_KILO_WATT_HOUR, - "powerToday", - {"round": 1, "device_class": DEVICE_CLASS_ENERGY}, - ), - "inverter_energy_total": ( - "Lifetime energy output", - ENERGY_KILO_WATT_HOUR, - "powerTotal", - {"round": 1, "device_class": DEVICE_CLASS_ENERGY}, - ), - "inverter_voltage_input_1": ( - "Input 1 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv1", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_1": ( - "Input 1 Amperage", - ELECTRIC_CURRENT_AMPERE, - "ipv1", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_1": ( - "Input 1 Wattage", - POWER_WATT, - "ppv1", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_voltage_input_2": ( - "Input 2 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv2", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_2": ( - "Input 2 Amperage", - ELECTRIC_CURRENT_AMPERE, - "ipv2", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_2": ( - "Input 2 Wattage", - POWER_WATT, - "ppv2", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_voltage_input_3": ( - "Input 3 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv3", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_amperage_input_3": ( - "Input 3 Amperage", - ELECTRIC_CURRENT_AMPERE, - "ipv3", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_wattage_input_3": ( - "Input 3 Wattage", - POWER_WATT, - "ppv3", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_internal_wattage": ( - "Internal wattage", - POWER_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_reactive_voltage": ( - "Reactive voltage", - ELECTRIC_POTENTIAL_VOLT, - "vacr", - {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "inverter_inverter_reactive_amperage": ( - "Reactive amperage", - ELECTRIC_CURRENT_AMPERE, - "iacr", - {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, - ), - "inverter_frequency": ("AC frequency", FREQUENCY_HERTZ, "fac", {"round": 1}), - "inverter_current_wattage": ( - "Output power", - POWER_WATT, - "pac", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_current_reactive_wattage": ( - "Reactive wattage", - POWER_WATT, - "pacr", - {"device_class": DEVICE_CLASS_POWER, "round": 1}, - ), - "inverter_ipm_temperature": ( - "Intelligent Power Management temperature", - TEMP_CELSIUS, - "ipmTemperature", - {"device_class": DEVICE_CLASS_TEMPERATURE, "round": 1}, - ), - "inverter_temperature": ( - "Temperature", - TEMP_CELSIUS, - "temperature", - {"device_class": DEVICE_CLASS_TEMPERATURE, "round": 1}, - ), -} +@dataclass +class GrowattRequiredKeysMixin: + """Mixin for required keys.""" -STORAGE_SENSOR_TYPES = { - "storage_storage_production_today": ( - "Storage production today", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_storage_production_lifetime": ( - "Lifetime Storage production", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_grid_discharge_today": ( - "Grid discharged today", - ENERGY_KILO_WATT_HOUR, - "eacDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption_today": ( - "Load consumption today", - ENERGY_KILO_WATT_HOUR, - "eopDischrToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption_lifetime": ( - "Lifetime load consumption", - ENERGY_KILO_WATT_HOUR, - "eopDischrTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_grid_charged_today": ( - "Grid charged today", - ENERGY_KILO_WATT_HOUR, - "eacChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_charge_storage_lifetime": ( - "Lifetime storaged charged", - ENERGY_KILO_WATT_HOUR, - "eChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_solar_production": ( - "Solar power production", - POWER_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_battery_percentage": ( - "Battery percentage", - PERCENTAGE, - "capacity", - {"device_class": DEVICE_CLASS_BATTERY}, - ), - "storage_power_flow": ( - "Storage charging/ discharging(-ve)", - POWER_WATT, - "pCharge", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_load_consumption_solar_storage": ( - "Load consumption(Solar + Storage)", - "VA", - "rateVA", - {}, - ), - "storage_charge_today": ( - "Charge today", - ENERGY_KILO_WATT_HOUR, - "eChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_import_from_grid": ( - "Import from grid", - POWER_WATT, - "pAcInPut", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_import_from_grid_today": ( - "Import from grid today", - ENERGY_KILO_WATT_HOUR, - "eToUserToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_import_from_grid_total": ( - "Import from grid total", - ENERGY_KILO_WATT_HOUR, - "eToUserTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "storage_load_consumption": ( - "Load consumption", - POWER_WATT, - "outPutPower", - {"device_class": DEVICE_CLASS_POWER}, - ), - "storage_grid_voltage": ( - "AC input voltage", - ELECTRIC_POTENTIAL_VOLT, - "vGrid", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_pv_charging_voltage": ( - "PV charging voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_ac_input_frequency_out": ( - "AC input frequency", - FREQUENCY_HERTZ, - "freqOutPut", - {"round": 2}, - ), - "storage_output_voltage": ( - "Output voltage", - ELECTRIC_POTENTIAL_VOLT, - "outPutVolt", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_ac_output_frequency": ( - "Ac output frequency", - FREQUENCY_HERTZ, - "freqGrid", - {"round": 2}, - ), - "storage_current_PV": ( - "Solar charge current", - ELECTRIC_CURRENT_AMPERE, - "iAcCharge", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_current_1": ( - "Solar current to storage", - ELECTRIC_CURRENT_AMPERE, - "iChargePV1", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_grid_amperage_input": ( - "Grid charge current", - ELECTRIC_CURRENT_AMPERE, - "chgCurr", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_grid_out_current": ( - "Grid out current", - ELECTRIC_CURRENT_AMPERE, - "outPutCurrent", - {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, - ), - "storage_battery_voltage": ( - "Battery voltage", - ELECTRIC_POTENTIAL_VOLT, - "vBat", - {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, - ), - "storage_load_percentage": ( - "Load percentage", - PERCENTAGE, - "loadPercent", - {"device_class": DEVICE_CLASS_BATTERY, "round": 2}, - ), -} + api_key: str -MIX_SENSOR_TYPES = { - # Values from 'mix_info' API call - "mix_statement_of_charge": ( - "Statement of charge", - PERCENTAGE, - "capacity", - {"device_class": DEVICE_CLASS_BATTERY}, - ), - "mix_battery_charge_today": ( - "Battery charged today", - ENERGY_KILO_WATT_HOUR, - "eBatChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_battery_charge_lifetime": ( - "Lifetime battery charged", - ENERGY_KILO_WATT_HOUR, - "eBatChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_battery_discharge_today": ( - "Battery discharged today", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_battery_discharge_lifetime": ( - "Lifetime battery discharged", - ENERGY_KILO_WATT_HOUR, - "eBatDisChargeTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_solar_generation_today": ( - "Solar energy today", - ENERGY_KILO_WATT_HOUR, - "epvToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_solar_generation_lifetime": ( - "Lifetime solar energy", - ENERGY_KILO_WATT_HOUR, - "epvTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_battery_discharge_w": ( - "Battery discharging W", - POWER_WATT, - "pDischarge1", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_battery_voltage": ( - "Battery voltage", - ELECTRIC_POTENTIAL_VOLT, - "vbat", - {"device_class": DEVICE_CLASS_VOLTAGE}, - ), - "mix_pv1_voltage": ( - "PV1 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv1", - {"device_class": DEVICE_CLASS_VOLTAGE}, - ), - "mix_pv2_voltage": ( - "PV2 voltage", - ELECTRIC_POTENTIAL_VOLT, - "vpv2", - {"device_class": DEVICE_CLASS_VOLTAGE}, - ), - # Values from 'mix_totals' API call - "mix_load_consumption_today": ( - "Load consumption today", - ENERGY_KILO_WATT_HOUR, - "elocalLoadToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_load_consumption_lifetime": ( - "Lifetime load consumption", - ENERGY_KILO_WATT_HOUR, - "elocalLoadTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_export_to_grid_today": ( - "Export to grid today", - ENERGY_KILO_WATT_HOUR, - "etoGridToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_export_to_grid_lifetime": ( - "Lifetime export to grid", - ENERGY_KILO_WATT_HOUR, - "etogridTotal", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - # Values from 'mix_system_status' API call - "mix_battery_charge": ( - "Battery charging", - POWER_KILO_WATT, - "chargePower", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_load_consumption": ( - "Load consumption", - POWER_KILO_WATT, - "pLocalLoad", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_wattage_pv_1": ( - "PV1 Wattage", - POWER_KILO_WATT, - "pPv1", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_wattage_pv_2": ( - "PV2 Wattage", - POWER_KILO_WATT, - "pPv2", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_wattage_pv_all": ( - "All PV Wattage", - POWER_KILO_WATT, - "ppv", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_export_to_grid": ( - "Export to grid", - POWER_KILO_WATT, - "pactogrid", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_import_from_grid": ( - "Import from grid", - POWER_KILO_WATT, - "pactouser", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_battery_discharge_kw": ( - "Battery discharging kW", - POWER_KILO_WATT, - "pdisCharge1", - {"device_class": DEVICE_CLASS_POWER}, - ), - "mix_grid_voltage": ( - "Grid voltage", - ELECTRIC_POTENTIAL_VOLT, - "vAc1", - {"device_class": DEVICE_CLASS_VOLTAGE}, - ), - # Values from 'mix_detail' API call - "mix_system_production_today": ( - "System production today (self-consumption + export)", - ENERGY_KILO_WATT_HOUR, - "eCharge", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_load_consumption_solar_today": ( - "Load consumption today (solar)", - ENERGY_KILO_WATT_HOUR, - "eChargeToday", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_self_consumption_today": ( - "Self consumption today (solar + battery)", - ENERGY_KILO_WATT_HOUR, - "eChargeToday1", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_load_consumption_battery_today": ( - "Load consumption today (battery)", - ENERGY_KILO_WATT_HOUR, - "echarge1", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - "mix_import_from_grid_today": ( - "Import from grid today (load)", - ENERGY_KILO_WATT_HOUR, - "etouser", - {"device_class": DEVICE_CLASS_ENERGY}, - ), - # This sensor is manually created using the most recent X-Axis value from the chartData - "mix_last_update": ( - "Last Data Update", - None, - "lastdataupdate", - {"device_class": DEVICE_CLASS_TIMESTAMP}, - ), - # Values from 'dashboard_data' API call - "mix_import_from_grid_today_combined": ( - "Import from grid today (load + charging)", - ENERGY_KILO_WATT_HOUR, - "etouser_combined", # This id is not present in the raw API data, it is added by the sensor - {"device_class": DEVICE_CLASS_ENERGY}, - ), -} -SENSOR_TYPES = { - **TOTAL_SENSOR_TYPES, - **INVERTER_SENSOR_TYPES, - **STORAGE_SENSOR_TYPES, - **MIX_SENSOR_TYPES, -} +@dataclass +class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): + """Describes Growatt sensor entity.""" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PLANT_ID, default=DEFAULT_PLANT_ID): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_URL, default=DEFAULT_URL): cv.string, - } + precision: int | None = None + + +TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="total_money_today", + name="Total money today", + api_key="plantMoneyText", + native_unit_of_measurement=CURRENCY_EURO, + ), + GrowattSensorEntityDescription( + key="total_money_total", + name="Money lifetime", + api_key="totalMoneyText", + native_unit_of_measurement=CURRENCY_EURO, + ), + GrowattSensorEntityDescription( + key="total_energy_today", + name="Energy Today", + api_key="todayEnergy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="total_output_power", + name="Output Power", + api_key="invTodayPpv", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="total_energy_output", + name="Lifetime energy output", + api_key="totalEnergy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="total_maximum_output", + name="Maximum power", + api_key="nominalPower", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), ) +INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="inverter_energy_today", + name="Energy today", + api_key="powerToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_energy_total", + name="Lifetime energy output", + api_key="powerTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + precision=1, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_1", + name="Input 1 voltage", + api_key="vpv1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_1", + name="Input 1 Amperage", + api_key="ipv1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_1", + name="Input 1 Wattage", + api_key="ppv1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_2", + name="Input 2 voltage", + api_key="vpv2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_2", + name="Input 2 Amperage", + api_key="ipv2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_2", + name="Input 2 Wattage", + api_key="ppv2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_voltage_input_3", + name="Input 3 voltage", + api_key="vpv3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_amperage_input_3", + name="Input 3 Amperage", + api_key="ipv3", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_wattage_input_3", + name="Input 3 Wattage", + api_key="ppv3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_internal_wattage", + name="Internal wattage", + api_key="ppv", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_reactive_voltage", + name="Reactive voltage", + api_key="vacr", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_inverter_reactive_amperage", + name="Reactive amperage", + api_key="iacr", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_frequency", + name="AC frequency", + api_key="fac", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_current_wattage", + name="Output power", + api_key="pac", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_current_reactive_wattage", + name="Reactive wattage", + api_key="pacr", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_ipm_temperature", + name="Intelligent Power Management temperature", + api_key="ipmTemperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="inverter_temperature", + name="Temperature", + api_key="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), +) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up growatt server from yaml.""" - if not hass.config_entries.async_entries(DOMAIN): - _LOGGER.warning( - "Loading Growatt 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 - ) - ) +TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="tlx_energy_today", + name="Energy today", + api_key="eacToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total", + name="Lifetime energy output", + api_key="eacTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total_input_1", + name="Lifetime total energy input 1", + api_key="epv1Total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_voltage_input_1", + name="Input 1 voltage", + api_key="vpv1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_amperage_input_1", + name="Input 1 Amperage", + api_key="ipv1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_wattage_input_1", + name="Input 1 Wattage", + api_key="ppv1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_energy_total_input_2", + name="Lifetime total energy input 2", + api_key="epv2Total", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_voltage_input_2", + name="Input 2 voltage", + api_key="vpv2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_amperage_input_2", + name="Input 2 Amperage", + api_key="ipv2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_wattage_input_2", + name="Input 2 Wattage", + api_key="ppv2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_internal_wattage", + name="Internal wattage", + api_key="ppv", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_reactive_voltage", + name="Reactive voltage", + api_key="vacrs", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_frequency", + name="AC frequency", + api_key="fac", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_current_wattage", + name="Output power", + api_key="pac", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_1", + name="Temperature 1", + api_key="temp1", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_2", + name="Temperature 2", + api_key="temp2", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_3", + name="Temperature 3", + api_key="temp3", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_4", + name="Temperature 4", + api_key="temp4", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_temperature_5", + name="Temperature 5", + api_key="temp5", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + precision=1, + ), +) + +STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + GrowattSensorEntityDescription( + key="storage_storage_production_today", + name="Storage production today", + api_key="eBatDisChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_storage_production_lifetime", + name="Lifetime Storage production", + api_key="eBatDisChargeTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="storage_grid_discharge_today", + name="Grid discharged today", + api_key="eacDisChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_today", + name="Load consumption today", + api_key="eopDischrToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_lifetime", + name="Lifetime load consumption", + api_key="eopDischrTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="storage_grid_charged_today", + name="Grid charged today", + api_key="eacChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_charge_storage_lifetime", + name="Lifetime storaged charged", + api_key="eChargeTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="storage_solar_production", + name="Solar power production", + api_key="ppv", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="storage_battery_percentage", + name="Battery percentage", + api_key="capacity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), + GrowattSensorEntityDescription( + key="storage_power_flow", + name="Storage charging/ discharging(-ve)", + api_key="pCharge", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption_solar_storage", + name="Load consumption(Solar + Storage)", + api_key="rateVA", + native_unit_of_measurement="VA", + ), + GrowattSensorEntityDescription( + key="storage_charge_today", + name="Charge today", + api_key="eChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid", + name="Import from grid", + api_key="pAcInPut", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid_today", + name="Import from grid today", + api_key="eToUserToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="storage_import_from_grid_total", + name="Import from grid total", + api_key="eToUserTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="storage_load_consumption", + name="Load consumption", + api_key="outPutPower", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="storage_grid_voltage", + name="AC input voltage", + api_key="vGrid", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_pv_charging_voltage", + name="PV charging voltage", + api_key="vpv", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_ac_input_frequency_out", + name="AC input frequency", + api_key="freqOutPut", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_output_voltage", + name="Output voltage", + api_key="outPutVolt", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_ac_output_frequency", + name="Ac output frequency", + api_key="freqGrid", + native_unit_of_measurement=FREQUENCY_HERTZ, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_current_PV", + name="Solar charge current", + api_key="iAcCharge", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_current_1", + name="Solar current to storage", + api_key="iChargePV1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_grid_amperage_input", + name="Grid charge current", + api_key="chgCurr", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_grid_out_current", + name="Grid out current", + api_key="outPutCurrent", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_battery_voltage", + name="Battery voltage", + api_key="vBat", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + precision=2, + ), + GrowattSensorEntityDescription( + key="storage_load_percentage", + name="Load percentage", + api_key="loadPercent", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + precision=2, + ), +) + +MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + # Values from 'mix_info' API call + GrowattSensorEntityDescription( + key="mix_statement_of_charge", + name="Statement of charge", + api_key="capacity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), + GrowattSensorEntityDescription( + key="mix_battery_charge_today", + name="Battery charged today", + api_key="eBatChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_battery_charge_lifetime", + name="Lifetime battery charged", + api_key="eBatChargeTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_today", + name="Battery discharged today", + api_key="eBatDisChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_lifetime", + name="Lifetime battery discharged", + api_key="eBatDisChargeTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_solar_generation_today", + name="Solar energy today", + api_key="epvToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_solar_generation_lifetime", + name="Lifetime solar energy", + api_key="epvTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_w", + name="Battery discharging W", + api_key="pDischarge1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_battery_voltage", + name="Battery voltage", + api_key="vbat", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + GrowattSensorEntityDescription( + key="mix_pv1_voltage", + name="PV1 voltage", + api_key="vpv1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + GrowattSensorEntityDescription( + key="mix_pv2_voltage", + name="PV2 voltage", + api_key="vpv2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + # Values from 'mix_totals' API call + GrowattSensorEntityDescription( + key="mix_load_consumption_today", + name="Load consumption today", + api_key="elocalLoadToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_lifetime", + name="Lifetime load consumption", + api_key="elocalLoadTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid_today", + name="Export to grid today", + api_key="etoGridToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid_lifetime", + name="Lifetime export to grid", + api_key="etogridTotal", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + # Values from 'mix_system_status' API call + GrowattSensorEntityDescription( + key="mix_battery_charge", + name="Battery charging", + api_key="chargePower", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption", + name="Load consumption", + api_key="pLocalLoad", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_wattage_pv_1", + name="PV1 Wattage", + api_key="pPv1", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_wattage_pv_2", + name="PV2 Wattage", + api_key="pPv2", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_wattage_pv_all", + name="All PV Wattage", + api_key="ppv", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid", + name="Export to grid", + api_key="pactogrid", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_import_from_grid", + name="Import from grid", + api_key="pactouser", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_kw", + name="Battery discharging kW", + api_key="pdisCharge1", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + ), + GrowattSensorEntityDescription( + key="mix_grid_voltage", + name="Grid voltage", + api_key="vAc1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + # Values from 'mix_detail' API call + GrowattSensorEntityDescription( + key="mix_system_production_today", + name="System production today (self-consumption + export)", + api_key="eCharge", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_solar_today", + name="Load consumption today (solar)", + api_key="eChargeToday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_self_consumption_today", + name="Self consumption today (solar + battery)", + api_key="eChargeToday1", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_battery_today", + name="Load consumption today (battery)", + api_key="echarge1", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + GrowattSensorEntityDescription( + key="mix_import_from_grid_today", + name="Import from grid today (load)", + api_key="etouser", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + # This sensor is manually created using the most recent X-Axis value from the chartData + GrowattSensorEntityDescription( + key="mix_last_update", + name="Last Data Update", + api_key="lastdataupdate", + native_unit_of_measurement=None, + device_class=DEVICE_CLASS_TIMESTAMP, + ), + # Values from 'dashboard_data' API call + GrowattSensorEntityDescription( + key="mix_import_from_grid_today_combined", + name="Import from grid today (load + charging)", + api_key="etouser_combined", # This id is not present in the raw API data, it is added by the sensor + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) def get_device_list(api, config): @@ -606,42 +884,51 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) - entities = [] probe = GrowattData(api, username, password, plant_id, "total") - for sensor in TOTAL_SENSOR_TYPES: - entities.append( - GrowattInverter(probe, f"{name} Total", sensor, f"{plant_id}-{sensor}") + entities = [ + GrowattInverter( + probe, + name=f"{name} Total", + unique_id=f"{plant_id}-{description.key}", + description=description, ) + for description in TOTAL_SENSOR_TYPES + ] # Add sensors for each device in the specified plant. for device in devices: probe = GrowattData( api, username, password, device["deviceSn"], device["deviceType"] ) - sensors = [] + sensor_descriptions = () if device["deviceType"] == "inverter": - sensors = INVERTER_SENSOR_TYPES + sensor_descriptions = INVERTER_SENSOR_TYPES + elif device["deviceType"] == "tlx": + probe.plant_id = plant_id + sensor_descriptions = TLX_SENSOR_TYPES elif device["deviceType"] == "storage": probe.plant_id = plant_id - sensors = STORAGE_SENSOR_TYPES + sensor_descriptions = STORAGE_SENSOR_TYPES elif device["deviceType"] == "mix": probe.plant_id = plant_id - sensors = MIX_SENSOR_TYPES + sensor_descriptions = MIX_SENSOR_TYPES else: _LOGGER.debug( "Device type %s was found but is not supported right now", device["deviceType"], ) - for sensor in sensors: - entities.append( + entities.extend( + [ GrowattInverter( probe, - f"{device['deviceAilas']}", - sensor, - f"{device['deviceSn']}-{sensor}", + name=f"{device['deviceAilas']}", + unique_id=f"{device['deviceSn']}-{description.key}", + description=description, ) - ) + for description in sensor_descriptions + ] + ) async_add_entities(entities, True) @@ -649,48 +936,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class GrowattInverter(SensorEntity): """Representation of a Growatt Sensor.""" - def __init__(self, probe, name, sensor, unique_id): + entity_description: GrowattSensorEntityDescription + + def __init__( + self, probe, name, unique_id, description: GrowattSensorEntityDescription + ): """Initialize a PVOutput sensor.""" - self.sensor = sensor self.probe = probe - self._name = name - self._state = None - self._unique_id = unique_id + self.entity_description = description + + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + self._attr_icon = "mdi:solar-power" @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {SENSOR_TYPES[self.sensor][0]}" - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return self._unique_id - - @property - def icon(self): - """Return the icon of the sensor.""" - return "mdi:solar-power" - - @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - result = self.probe.get_data(SENSOR_TYPES[self.sensor][2]) - round_to = SENSOR_TYPES[self.sensor][3].get("round") - if round_to is not None: - result = round(result, round_to) + result = self.probe.get_data(self.entity_description.api_key) + if self.entity_description.precision is not None: + result = round(result, self.entity_description.precision) return result - @property - def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_TYPES[self.sensor][3].get("device_class") - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self.sensor][1] - def update(self): """Get the latest data from the Growat API and updates the state.""" self.probe.update() @@ -714,7 +980,7 @@ class GrowattData: def update(self): """Update probe data.""" self.api.login(self.username, self.password) - _LOGGER.debug("Updating data for %s", self.device_id) + _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.growatt_type) try: if self.growatt_type == "total": total_info = self.api.plant_info(self.device_id) @@ -727,6 +993,9 @@ class GrowattData: elif self.growatt_type == "inverter": inverter_info = self.api.inverter_detail(self.device_id) self.data = inverter_info + elif self.growatt_type == "tlx": + tlx_info = self.api.tlx_detail(self.device_id) + self.data = tlx_info["data"] elif self.growatt_type == "storage": storage_info_detail = self.api.storage_params(self.device_id)[ "storageDetailBean" @@ -763,7 +1032,9 @@ class GrowattData: # Dashboard values have units e.g. "kWh" as part of their returned string, so we remove it dashboard_values_for_mix = { # etouser is already used by the results from 'mix_detail' so we rebrand it as 'etouser_combined' - "etouser_combined": dashboard_data["etouser"].replace("kWh", "") + "etouser_combined": float( + dashboard_data["etouser"].replace("kWh", "") + ) } self.data = { **mix_info, diff --git a/homeassistant/components/growatt_server/translations/hu.json b/homeassistant/components/growatt_server/translations/hu.json index d856d13a96b..5b2efc737fe 100644 --- a/homeassistant/components/growatt_server/translations/hu.json +++ b/homeassistant/components/growatt_server/translations/hu.json @@ -17,6 +17,7 @@ "data": { "name": "N\u00e9v", "password": "Jelsz\u00f3", + "url": "URL", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "title": "Adja meg Growatt adatait" diff --git a/homeassistant/components/growatt_server/translations/no.json b/homeassistant/components/growatt_server/translations/no.json index dee1e989465..8977a7e86a3 100644 --- a/homeassistant/components/growatt_server/translations/no.json +++ b/homeassistant/components/growatt_server/translations/no.json @@ -17,6 +17,7 @@ "data": { "name": "Navn", "password": "Passord", + "url": "URL", "username": "Brukernavn" }, "title": "Skriv inn Growatt-informasjonen din" diff --git a/homeassistant/components/growatt_server/translations/zh-Hans.json b/homeassistant/components/growatt_server/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 812e6a58f28..f97bc9796ec 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -11,7 +11,10 @@ import pygtfs from sqlalchemy.sql import text import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + SensorEntity, +) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_NAME, @@ -254,8 +257,8 @@ WHEELCHAIR_ACCESS_OPTIONS = {1: True, 2: False} WHEELCHAIR_BOARDING_DEFAULT = STATE_UNKNOWN WHEELCHAIR_BOARDING_OPTIONS = {1: True, 2: False} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { # type: ignore +PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( + { vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_DATA): cv.string, @@ -490,7 +493,7 @@ def setup_platform( origin = config.get(CONF_ORIGIN) destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) - offset = config.get(CONF_OFFSET) + offset: datetime.timedelta = config[CONF_OFFSET] include_tomorrow = config[CONF_TOMORROW] if not os.path.exists(gtfs_dir): @@ -541,10 +544,10 @@ class GTFSDepartureSensor(SensorEntity): self._icon = ICON self._name = "" self._state: str | None = None - self._attributes = {} + self._attributes: dict[str, Any] = {} self._agency = None - self._departure = {} + self._departure: dict[str, Any] = {} self._destination = None self._origin = None self._route = None @@ -559,7 +562,7 @@ class GTFSDepartureSensor(SensorEntity): return self._name @property - def state(self) -> str | None: # type: ignore + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 915746c5ed5..94413c76578 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -213,20 +214,13 @@ class GuardianEntity(CoordinatorEntity): """Define a base Guardian entity.""" def __init__( # pylint: disable=super-init-not-called - self, - entry: ConfigEntry, - kind: str, - name: str, - device_class: str | None, - icon: str | None, + self, entry: ConfigEntry, description: EntityDescription ) -> None: """Initialize.""" - self._attr_device_class = device_class self._attr_device_info = {"manufacturer": "Elexa"} self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: "Data provided by Elexa"} - self._attr_icon = icon - self._attr_name = name self._entry = entry + self.entity_description = description @callback def _async_update_from_latest_data(self) -> None: @@ -244,13 +238,10 @@ class PairedSensorEntity(GuardianEntity): self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: EntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, kind, name, device_class, icon) + super().__init__(entry, description) paired_sensor_uid = coordinator.data["uid"] self._attr_device_info = { @@ -258,9 +249,10 @@ class PairedSensorEntity(GuardianEntity): "name": f"Guardian Paired Sensor {paired_sensor_uid}", "via_device": (DOMAIN, entry.data[CONF_UID]), } - self._attr_name = f"Guardian Paired Sensor {paired_sensor_uid}: {name}" - self._attr_unique_id = f"{paired_sensor_uid}_{kind}" - self._kind = kind + self._attr_name = ( + f"Guardian Paired Sensor {paired_sensor_uid}: {description.name}" + ) + self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" self.coordinator = coordinator async def async_added_to_hass(self) -> None: @@ -275,22 +267,18 @@ class ValveControllerEntity(GuardianEntity): self, entry: ConfigEntry, coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: EntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, kind, name, device_class, icon) + super().__init__(entry, description) self._attr_device_info = { "identifiers": {(DOMAIN, entry.data[CONF_UID])}, "name": f"Guardian Valve Controller {entry.data[CONF_UID]}", "model": coordinators[API_SYSTEM_DIAGNOSTICS].data["firmware"], } - self._attr_name = f"Guardian {entry.data[CONF_UID]}: {name}" - self._attr_unique_id = f"{entry.data[CONF_UID]}_{kind}" - self._kind = kind + self._attr_name = f"Guardian {entry.data[CONF_UID]}: {description.name}" + self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}" self.coordinators = coordinators @property diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 1cbc9f5cede..b420605a0ec 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOVING, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -31,14 +32,30 @@ SENSOR_KIND_AP_INFO = "ap_enabled" SENSOR_KIND_LEAK_DETECTED = "leak_detected" SENSOR_KIND_MOVED = "moved" -SENSOR_ATTRS_MAP = { - SENSOR_KIND_AP_INFO: ("Onboard AP Enabled", DEVICE_CLASS_CONNECTIVITY), - SENSOR_KIND_LEAK_DETECTED: ("Leak Detected", DEVICE_CLASS_MOISTURE), - SENSOR_KIND_MOVED: ("Recently Moved", DEVICE_CLASS_MOVING), -} +SENSOR_DESCRIPTION_AP_ENABLED = BinarySensorEntityDescription( + key=SENSOR_KIND_AP_INFO, + name="Onboard AP Enabled", + device_class=DEVICE_CLASS_CONNECTIVITY, +) +SENSOR_DESCRIPTION_LEAK_DETECTED = BinarySensorEntityDescription( + key=SENSOR_KIND_LEAK_DETECTED, + name="Leak Detected", + device_class=DEVICE_CLASS_MOISTURE, +) +SENSOR_DESCRIPTION_MOVED = BinarySensorEntityDescription( + key=SENSOR_KIND_MOVED, + name="Recently Moved", + device_class=DEVICE_CLASS_MOVING, +) -PAIRED_SENSOR_SENSORS = [SENSOR_KIND_LEAK_DETECTED, SENSOR_KIND_MOVED] -VALVE_CONTROLLER_SENSORS = [SENSOR_KIND_AP_INFO, SENSOR_KIND_LEAK_DETECTED] +PAIRED_SENSOR_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_LEAK_DETECTED, + SENSOR_DESCRIPTION_MOVED, +) +VALVE_CONTROLLER_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_AP_ENABLED, + SENSOR_DESCRIPTION_LEAK_DETECTED, +) async def async_setup_entry( @@ -53,21 +70,12 @@ async def async_setup_entry( uid ] - entities = [] - for kind in PAIRED_SENSOR_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - entities.append( - PairedSensorBinarySensor( - entry, - coordinator, - kind, - name, - device_class, - None, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + PairedSensorBinarySensor(entry, coordinator, description) + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) # Handle adding paired sensors after HASS startup: hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id].append( @@ -78,38 +86,24 @@ async def async_setup_entry( ) ) - sensors: list[PairedSensorBinarySensor | ValveControllerBinarySensor] = [] - # Add all valve controller-specific binary sensors: - for kind in VALVE_CONTROLLER_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - sensors.append( - ValveControllerBinarySensor( - entry, - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], - kind, - name, - device_class, - None, - ) + sensors: list[PairedSensorBinarySensor | ValveControllerBinarySensor] = [ + ValveControllerBinarySensor( + entry, hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], description ) + for description in VALVE_CONTROLLER_DESCRIPTIONS + ] # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ - entry.entry_id - ].values(): - for kind in PAIRED_SENSOR_SENSORS: - name, device_class = SENSOR_ATTRS_MAP[kind] - sensors.append( - PairedSensorBinarySensor( - entry, - coordinator, - kind, - name, - device_class, - None, - ) - ) + sensors.extend( + [ + PairedSensorBinarySensor(entry, coordinator, description) + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + entry.entry_id + ].values() + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -121,22 +115,19 @@ class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: BinarySensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, coordinator, kind, name, device_class, icon) + super().__init__(entry, coordinator, description) self._attr_is_on = True @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_LEAK_DETECTED: + if self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: self._attr_is_on = self.coordinator.data["wet"] - elif self._kind == SENSOR_KIND_MOVED: + elif self.entity_description.key == SENSOR_KIND_MOVED: self._attr_is_on = self.coordinator.data["moved"] @@ -147,27 +138,24 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): self, entry: ConfigEntry, coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str | None, - icon: str | None, + description: BinarySensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(entry, coordinators, kind, name, device_class, icon) + super().__init__(entry, coordinators, description) self._attr_is_on = True async def _async_continue_entity_setup(self) -> None: """Add an API listener.""" - if self._kind == SENSOR_KIND_AP_INFO: + if self.entity_description.key == SENSOR_KIND_AP_INFO: self.async_add_coordinator_update_listener(API_WIFI_STATUS) - elif self._kind == SENSOR_KIND_LEAK_DETECTED: + elif self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: self.async_add_coordinator_update_listener(API_SYSTEM_ONBOARD_SENSOR_STATUS) @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_AP_INFO: + if self.entity_description.key == SENSOR_KIND_AP_INFO: self._attr_available = self.coordinators[ API_WIFI_STATUS ].last_update_success @@ -181,7 +169,7 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): ) } ) - elif self._kind == SENSOR_KIND_LEAK_DETECTED: + elif self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: self._attr_available = self.coordinators[ API_SYSTEM_ONBOARD_SENSOR_STATUS ].last_update_success diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 2d7cde86cca..fb7952669cc 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,7 +1,7 @@ """Sensors for the Elexa Guardian integration.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -13,7 +13,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PairedSensorEntity, ValveControllerEntity from .const import ( @@ -31,19 +30,33 @@ SENSOR_KIND_BATTERY = "battery" SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_UPTIME = "uptime" -SENSOR_ATTRS_MAP = { - SENSOR_KIND_BATTERY: ("Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE), - SENSOR_KIND_TEMPERATURE: ( - "Temperature", - DEVICE_CLASS_TEMPERATURE, - None, - TEMP_FAHRENHEIT, - ), - SENSOR_KIND_UPTIME: ("Uptime", None, "mdi:timer", TIME_MINUTES), -} +SENSOR_DESCRIPTION_BATTERY = SensorEntityDescription( + key=SENSOR_KIND_BATTERY, + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, +) +SENSOR_DESCRIPTION_TEMPERATURE = SensorEntityDescription( + key=SENSOR_KIND_TEMPERATURE, + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_FAHRENHEIT, +) +SENSOR_DESCRIPTION_UPTIME = SensorEntityDescription( + key=SENSOR_KIND_UPTIME, + name="Uptime", + icon="mdi:timer", + native_unit_of_measurement=TIME_MINUTES, +) -PAIRED_SENSOR_SENSORS = [SENSOR_KIND_BATTERY, SENSOR_KIND_TEMPERATURE] -VALVE_CONTROLLER_SENSORS = [SENSOR_KIND_TEMPERATURE, SENSOR_KIND_UPTIME] +PAIRED_SENSOR_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_BATTERY, + SENSOR_DESCRIPTION_TEMPERATURE, +) +VALVE_CONTROLLER_DESCRIPTIONS = ( + SENSOR_DESCRIPTION_TEMPERATURE, + SENSOR_DESCRIPTION_UPTIME, +) async def async_setup_entry( @@ -58,16 +71,12 @@ async def async_setup_entry( uid ] - entities = [] - for kind in PAIRED_SENSOR_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - entities.append( - PairedSensorSensor( - entry, coordinator, kind, name, device_class, icon, unit - ) - ) - - async_add_entities(entities, True) + async_add_entities( + [ + PairedSensorSensor(entry, coordinator, description) + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) # Handle adding paired sensors after HASS startup: hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id].append( @@ -78,34 +87,24 @@ async def async_setup_entry( ) ) - sensors: list[PairedSensorSensor | ValveControllerSensor] = [] - # Add all valve controller-specific binary sensors: - for kind in VALVE_CONTROLLER_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - sensors.append( - ValveControllerSensor( - entry, - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], - kind, - name, - device_class, - icon, - unit, - ) + sensors: list[PairedSensorSensor | ValveControllerSensor] = [ + ValveControllerSensor( + entry, hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id], description ) + for description in VALVE_CONTROLLER_DESCRIPTIONS + ] # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ - entry.entry_id - ].values(): - for kind in PAIRED_SENSOR_SENSORS: - name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] - sensors.append( - PairedSensorSensor( - entry, coordinator, kind, name, device_class, icon, unit - ) - ) + sensors.extend( + [ + PairedSensorSensor(entry, coordinator, description) + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + entry.entry_id + ].values() + for description in PAIRED_SENSOR_DESCRIPTIONS + ] + ) async_add_entities(sensors) @@ -113,65 +112,37 @@ async def async_setup_entry( class PairedSensorSensor(PairedSensorEntity, SensorEntity): """Define a binary sensor related to a Guardian valve controller.""" - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - kind: str, - name: str, - device_class: str | None, - icon: str | None, - unit: str | None, - ) -> None: - """Initialize.""" - super().__init__(entry, coordinator, kind, name, device_class, icon) - - self._attr_unit_of_measurement = unit - @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_BATTERY: - self._attr_state = self.coordinator.data["battery"] - elif self._kind == SENSOR_KIND_TEMPERATURE: - self._attr_state = self.coordinator.data["temperature"] + if self.entity_description.key == SENSOR_KIND_BATTERY: + self._attr_native_value = self.coordinator.data["battery"] + elif self.entity_description.key == SENSOR_KIND_TEMPERATURE: + self._attr_native_value = self.coordinator.data["temperature"] class ValveControllerSensor(ValveControllerEntity, SensorEntity): """Define a generic Guardian sensor.""" - def __init__( - self, - entry: ConfigEntry, - coordinators: dict[str, DataUpdateCoordinator], - kind: str, - name: str, - device_class: str | None, - icon: str | None, - unit: str | None, - ) -> None: - """Initialize.""" - super().__init__(entry, coordinators, kind, name, device_class, icon) - - self._attr_unit_of_measurement = unit - async def _async_continue_entity_setup(self) -> None: """Register API interest (and related tasks) when the entity is added.""" - if self._kind == SENSOR_KIND_TEMPERATURE: + if self.entity_description.key == SENSOR_KIND_TEMPERATURE: self.async_add_coordinator_update_listener(API_SYSTEM_ONBOARD_SENSOR_STATUS) @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - if self._kind == SENSOR_KIND_TEMPERATURE: + if self.entity_description.key == SENSOR_KIND_TEMPERATURE: self._attr_available = self.coordinators[ API_SYSTEM_ONBOARD_SENSOR_STATUS ].last_update_success - self._attr_state = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ - "temperature" - ] - elif self._kind == SENSOR_KIND_UPTIME: + self._attr_native_value = self.coordinators[ + API_SYSTEM_ONBOARD_SENSOR_STATUS + ].data["temperature"] + elif self.entity_description.key == SENSOR_KIND_UPTIME: self._attr_available = self.coordinators[ API_SYSTEM_DIAGNOSTICS ].last_update_success - self._attr_state = self.coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"] + self._attr_native_value = self.coordinators[API_SYSTEM_DIAGNOSTICS].data[ + "uptime" + ] diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index f3621a72952..9b7db16dd15 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -7,7 +7,7 @@ from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILENAME, CONF_PORT, CONF_URL from homeassistant.core import HomeAssistant, callback @@ -39,6 +39,14 @@ SERVICE_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" SERVICE_UNPAIR_SENSOR = "unpair_sensor" SERVICE_UPGRADE_FIRMWARE = "upgrade_firmware" +SWITCH_KIND_VALVE = "valve" + +SWITCH_DESCRIPTION_VALVE = SwitchEntityDescription( + key=SWITCH_KIND_VALVE, + name="Valve Controller", + icon="mdi:water", +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -90,9 +98,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): coordinators: dict[str, DataUpdateCoordinator], ) -> None: """Initialize.""" - super().__init__( - entry, coordinators, "valve", "Valve Controller", None, "mdi:water" - ) + super().__init__(entry, coordinators, SWITCH_DESCRIPTION_VALVE) self._attr_is_on = True self._client = client @@ -201,7 +207,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while upgrading firmware: %s", err) - async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the valve off (closed).""" try: async with self._client: @@ -213,7 +219,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._attr_is_on = False self.async_write_ha_state() - async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the valve on (open).""" try: async with self._client: diff --git a/homeassistant/components/guardian/translations/hu.json b/homeassistant/components/guardian/translations/hu.json index ca9a746f9d9..15469bead1e 100644 --- a/homeassistant/components/guardian/translations/hu.json +++ b/homeassistant/components/guardian/translations/hu.json @@ -13,7 +13,11 @@ "data": { "ip_address": "IP c\u00edm", "port": "Port" - } + }, + "description": "Konfigur\u00e1lja a helyi Elexa Guardian eszk\u00f6zt." + }, + "zeroconf_confirm": { + "description": "Be akarja \u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" } } } diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index efb82a9f1aa..1d1536d1679 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ARGS, @@ -83,7 +84,7 @@ SERVICE_API_CALL_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Habitica service.""" configs = config.get(DOMAIN, []) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 52748ddadad..eb42426e8ea 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -155,12 +155,12 @@ class HabitipySensor(SensorEntity): return f"{DOMAIN}_{self._name}_{self._sensor_name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._sensor_type.unit @@ -195,7 +195,7 @@ class HabitipyTaskSensor(SensorEntity): return f"{DOMAIN}_{self._name}_{self._task_name}" @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -220,6 +220,6 @@ class HabitipyTaskSensor(SensorEntity): return attrs @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._task_type.unit diff --git a/homeassistant/components/hangouts/translations/he.json b/homeassistant/components/hangouts/translations/he.json index fa756c49ac6..9f0e3b48a62 100644 --- a/homeassistant/components/hangouts/translations/he.json +++ b/homeassistant/components/hangouts/translations/he.json @@ -14,7 +14,6 @@ "data": { "2fa": "\u05e7\u05d5\u05d3 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9" }, - "description": "\u05e8\u05d9\u05e7", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9" }, "user": { @@ -22,7 +21,6 @@ "email": "\u05d3\u05d5\u05d0\"\u05dc", "password": "\u05e1\u05d9\u05e1\u05de\u05d4" }, - "description": "\u05e8\u05d9\u05e7", "title": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc- Google Hangouts" } } diff --git a/homeassistant/components/hangouts/translations/hu.json b/homeassistant/components/hangouts/translations/hu.json index 3c065b01169..2f02ba9f623 100644 --- a/homeassistant/components/hangouts/translations/hu.json +++ b/homeassistant/components/hangouts/translations/hu.json @@ -19,6 +19,7 @@ }, "user": { "data": { + "authorization_code": "Enged\u00e9lyez\u00e9si k\u00f3d (k\u00e9zi hiteles\u00edt\u00e9shez sz\u00fcks\u00e9ges)", "email": "E-mail", "password": "Jelsz\u00f3" }, diff --git a/homeassistant/components/hangouts/translations/lt.json b/homeassistant/components/hangouts/translations/lt.json new file mode 100644 index 00000000000..13dbbf8bdbc --- /dev/null +++ b/homeassistant/components/hangouts/translations/lt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "2 veiksni\u0173 autentifikavimas" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/hu.json b/homeassistant/components/harmony/translations/hu.json index a9cb6ccecee..4922bbd1ac6 100644 --- a/homeassistant/components/harmony/translations/hu.json +++ b/homeassistant/components/harmony/translations/hu.json @@ -7,11 +7,29 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, + "flow_title": "{name}", "step": { + "link": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" + }, "user": { "data": { - "host": "Hoszt" - } + "host": "Hoszt", + "name": "Hub neve" + }, + "title": "A Logitech Harmony Hub be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Az alap\u00e9rtelmezett tev\u00e9kenys\u00e9g, amelyet akkor kell v\u00e9grehajtani, ha nincs megadva.", + "delay_secs": "A parancsok k\u00fcld\u00e9se k\u00f6z\u00f6tti k\u00e9s\u00e9s." + }, + "description": "Harmony Hub be\u00e1ll\u00edt\u00e1sok" } } } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 6c71f2eb042..d55de8e275b 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -89,6 +89,8 @@ SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_REBOOT = "host_reboot" SERVICE_SNAPSHOT_FULL = "snapshot_full" SERVICE_SNAPSHOT_PARTIAL = "snapshot_partial" +SERVICE_BACKUP_FULL = "backup_full" +SERVICE_BACKUP_PARTIAL = "backup_partial" SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" @@ -101,11 +103,11 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} ) -SCHEMA_SNAPSHOT_FULL = vol.Schema( +SCHEMA_BACKUP_FULL = vol.Schema( {vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string} ) -SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend( +SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( { vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), @@ -113,7 +115,12 @@ SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend( ) SCHEMA_RESTORE_FULL = vol.Schema( - {vol.Required(ATTR_SNAPSHOT): cv.slug, vol.Optional(ATTR_PASSWORD): cv.string} + { + vol.Exclusive(ATTR_SLUG, ATTR_SLUG): cv.slug, + vol.Exclusive(ATTR_SNAPSHOT, ATTR_SLUG): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, + }, + cv.has_at_least_one_key(ATTR_SLUG, ATTR_SNAPSHOT), ) SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( @@ -133,25 +140,32 @@ MAP_SERVICE_API = { 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_SNAPSHOT_FULL: ("/snapshots/new/full", SCHEMA_SNAPSHOT_FULL, 300, True), - SERVICE_SNAPSHOT_PARTIAL: ( - "/snapshots/new/partial", - SCHEMA_SNAPSHOT_PARTIAL, + SERVICE_BACKUP_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), + SERVICE_BACKUP_PARTIAL: ( + "/backups/new/partial", + SCHEMA_BACKUP_PARTIAL, 300, True, ), SERVICE_RESTORE_FULL: ( - "/snapshots/{snapshot}/restore/full", + "/backups/{slug}/restore/full", SCHEMA_RESTORE_FULL, 300, True, ), SERVICE_RESTORE_PARTIAL: ( - "/snapshots/{snapshot}/restore/partial", + "/backups/{slug}/restore/partial", SCHEMA_RESTORE_PARTIAL, 300, True, ), + SERVICE_SNAPSHOT_FULL: ("/backups/new/full", SCHEMA_BACKUP_FULL, 300, True), + SERVICE_SNAPSHOT_PARTIAL: ( + "/backups/new/partial", + SCHEMA_BACKUP_PARTIAL, + 300, + True, + ), } @@ -272,16 +286,16 @@ async def async_get_addon_discovery_info(hass: HomeAssistant, slug: str) -> dict @bind_hass @api_data -async def async_create_snapshot( +async def async_create_backup( hass: HomeAssistant, payload: dict, partial: bool = False ) -> dict: - """Create a full or partial snapshot. + """Create a full or partial backup. The caller of the function should handle HassioAPIError. """ hassio = hass.data[DOMAIN] - snapshot_type = "partial" if partial else "full" - command = f"/snapshots/new/{snapshot_type}" + backup_type = "partial" if partial else "full" + command = f"/backups/new/{backup_type}" return await hassio.send_command(command, payload=payload, timeout=None) @@ -453,9 +467,22 @@ 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] + if "snapshot" in service.service: + _LOGGER.warning( + "The service '%s' is deprecated and will be removed in Home Assistant 2021.11, use '%s' instead", + service.service, + service.service.replace("snapshot", "backup"), + ) data = service.data.copy() addon = data.pop(ATTR_ADDON, None) + slug = data.pop(ATTR_SLUG, None) snapshot = data.pop(ATTR_SNAPSHOT, None) + if snapshot is not None: + _LOGGER.warning( + "Using 'snapshot' is deprecated and will be removed in Home Assistant 2021.11, use 'slug' instead" + ) + slug = snapshot + payload = None # Pass data to Hass.io API @@ -467,12 +494,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # Call API try: await hassio.send_command( - api_command.format(addon=addon, snapshot=snapshot), + api_command.format(addon=addon, slug=slug), payload=payload, timeout=MAP_SERVICE_API[service.service][2], ) except HassioAPIError as err: - _LOGGER.error("Error on Hass.io API: %s", err) + _LOGGER.error("Error on Supervisor API: %s", err) for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 01930b5ec0e..dfd13adbde6 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -1,15 +1,28 @@ """Binary sensor platform for Hass.io addons.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_UPDATE, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ADDONS_COORDINATOR -from .const import ATTR_UPDATE_AVAILABLE +from .const import ATTR_UPDATE_AVAILABLE, DATA_KEY_ADDONS, DATA_KEY_OS from .entity import HassioAddonEntity, HassioOSEntity +ENTITY_DESCRIPTIONS = ( + BinarySensorEntityDescription( + device_class=DEVICE_CLASS_UPDATE, + entity_registry_enabled_default=False, + key=ATTR_UPDATE_AVAILABLE, + name="Update Available", + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -19,16 +32,26 @@ async def async_setup_entry( """Binary sensor set up for Hass.io config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] - entities = [ - HassioAddonBinarySensor( - coordinator, addon, ATTR_UPDATE_AVAILABLE, "Update Available" - ) - for addon in coordinator.data["addons"].values() - ] - if coordinator.is_hass_os: - entities.append( - HassioOSBinarySensor(coordinator, ATTR_UPDATE_AVAILABLE, "Update Available") - ) + entities = [] + + for entity_description in ENTITY_DESCRIPTIONS: + for addon in coordinator.data[DATA_KEY_ADDONS].values(): + entities.append( + HassioAddonBinarySensor( + addon=addon, + coordinator=coordinator, + entity_description=entity_description, + ) + ) + + if coordinator.is_hass_os: + entities.append( + HassioOSBinarySensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + async_add_entities(entities) @@ -38,7 +61,9 @@ class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.addon_info[self.attribute_name] + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ + self.entity_description.key + ] class HassioOSBinarySensor(HassioOSEntity, BinarySensorEntity): @@ -47,4 +72,4 @@ class HassioOSBinarySensor(HassioOSEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.os_info[self.attribute_name] + return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 6104e57fb17..134fba15f70 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -40,7 +40,6 @@ WS_TYPE_SUBSCRIBE = "supervisor/subscribe" EVENT_SUPERVISOR_EVENT = "supervisor_event" -# Add-on keys ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_UPDATE_AVAILABLE = "update_available" @@ -49,6 +48,10 @@ ATTR_URL = "url" ATTR_REPOSITORY = "repository" +DATA_KEY_ADDONS = "addons" +DATA_KEY_OS = "os" + + class SupervisorEntityModel(str, Enum): """Supervisor entity model.""" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index e7f8df3b61d..9f15ff6e8b8 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -8,7 +8,7 @@ from aiohttp.web_exceptions import HTTPServiceUnavailable from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_NAME, ATTR_SERVICE, EVENT_HOMEASSISTANT_START -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from .const import ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_UUID from .handler import HassioAPIError @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_setup_discovery_view(hass: HomeAssistantView, hassio): +def async_setup_discovery_view(hass: HomeAssistant, hassio): """Discovery setup.""" hassio_discovery = HassIODiscovery(hass, hassio) hass.http.register_view(hassio_discovery) @@ -49,7 +49,7 @@ class HassIODiscovery(HomeAssistantView): name = "api:hassio_push:discovery" url = "/api/hassio_push/discovery/{uuid}" - def __init__(self, hass: HomeAssistantView, hassio): + def __init__(self, hass: HomeAssistant, hassio): """Initialize WebView.""" self.hass = hass self.hassio = hassio diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 4885ba8979f..4a342e9965f 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.const import ATTR_NAME -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator @@ -17,42 +17,16 @@ class HassioAddonEntity(CoordinatorEntity): def __init__( self, coordinator: HassioDataUpdateCoordinator, + entity_description: EntityDescription, addon: dict[str, Any], - attribute_name: str, - sensor_name: str, ) -> None: """Initialize base entity.""" - self.addon_slug = addon[ATTR_SLUG] - self.addon_name = addon[ATTR_NAME] - self._data_key = "addons" - self.attribute_name = attribute_name - self.sensor_name = sensor_name super().__init__(coordinator) - - @property - def addon_info(self) -> dict[str, Any]: - """Return add-on info.""" - return self.coordinator.data[self._data_key][self.addon_slug] - - @property - def name(self) -> str: - """Return entity name.""" - return f"{self.addon_name}: {self.sensor_name}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def unique_id(self) -> str: - """Return unique ID for entity.""" - return f"{self.addon_slug}_{self.attribute_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return {"identifiers": {(DOMAIN, self.addon_slug)}} + self.entity_description = entity_description + self._addon_slug = addon[ATTR_SLUG] + self._attr_name = f"{addon[ATTR_NAME]}: {entity_description.name}" + self._attr_unique_id = f"{addon[ATTR_SLUG]}_{entity_description.key}" + self._attr_device_info = {"identifiers": {(DOMAIN, addon[ATTR_SLUG])}} class HassioOSEntity(CoordinatorEntity): @@ -61,36 +35,11 @@ class HassioOSEntity(CoordinatorEntity): def __init__( self, coordinator: HassioDataUpdateCoordinator, - attribute_name: str, - sensor_name: str, + entity_description: EntityDescription, ) -> None: """Initialize base entity.""" - self._data_key = "os" - self.attribute_name = attribute_name - self.sensor_name = sensor_name super().__init__(coordinator) - - @property - def os_info(self) -> dict[str, Any]: - """Return OS info.""" - return self.coordinator.data[self._data_key] - - @property - def name(self) -> str: - """Return entity name.""" - return f"Home Assistant Operating System: {self.sensor_name}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def unique_id(self) -> str: - """Return unique ID for entity.""" - return f"home_assistant_os_{self.attribute_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return {"identifiers": {(DOMAIN, "OS")}} + self.entity_description = entity_description + self._attr_name = f"Home Assistant Operating System: {entity_description.name}" + self._attr_unique_id = f"home_assistant_os_{entity_description.key}" + self._attr_device_info = {"identifiers": {(DOMAIN, "OS")}} diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 47131b80de3..fe01cbe3197 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -8,9 +8,15 @@ import re import aiohttp from aiohttp import web -from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE +from aiohttp.client import ClientTimeout +from aiohttp.hdrs import ( + CACHE_CONTROL, + CONTENT_ENCODING, + CONTENT_LENGTH, + CONTENT_TYPE, + TRANSFER_ENCODING, +) from aiohttp.web_exceptions import HTTPBadGateway -import async_timeout from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.onboarding import async_is_onboarded @@ -29,6 +35,9 @@ NO_TIMEOUT = re.compile( r"|hassos/update/cli" r"|supervisor/update" r"|addons/[^/]+/(?:update|install|rebuild)" + r"|backups/.+/full" + r"|backups/.+/partial" + r"|backups/[^/]+/(?:upload|download)" r"|snapshots/.+/full" r"|snapshots/.+/partial" r"|snapshots/[^/]+/(?:upload|download)" @@ -36,13 +45,15 @@ NO_TIMEOUT = re.compile( ) NO_AUTH_ONBOARDING = re.compile( - r"^(?:" r"|supervisor/logs" r"|snapshots/[^/]+/.+" r")$" + r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r"|snapshots/[^/]+/.+" r")$" ) NO_AUTH = re.compile( r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$" ) +NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") + class HassIOView(HomeAssistantView): """Hass.io view to handle base part.""" @@ -72,49 +83,32 @@ class HassIOView(HomeAssistantView): async def _command_proxy( self, path: str, request: web.Request - ) -> web.Response | web.StreamResponse: + ) -> web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. This method is a coroutine. """ - read_timeout = _get_timeout(path) - client_timeout = 10 - data = None headers = _init_header(request) - if path == "snapshots/new/upload": + if path in ("snapshots/new/upload", "backups/new/upload"): # We need to reuse the full content type that includes the boundary headers[ "Content-Type" ] = request._stored_content_type # pylint: disable=protected-access - # Snapshots are big, so we need to adjust the allowed size - request._client_max_size = ( # pylint: disable=protected-access - MAX_UPLOAD_SIZE - ) - client_timeout = 300 - try: - with async_timeout.timeout(client_timeout): - data = await request.read() - - method = getattr(self._websession, request.method.lower()) - client = await method( - f"http://{self._host}/{path}", - data=data, + client = await self._websession.request( + method=request.method, + url=f"http://{self._host}/{path}", + params=request.query, + data=request.content, headers=headers, - timeout=read_timeout, + timeout=_get_timeout(path), ) - # Simple request - if int(client.headers.get(CONTENT_LENGTH, 0)) < 4194000: - # Return Response - body = await client.read() - return web.Response( - content_type=client.content_type, status=client.status, body=body - ) - # Stream response - response = web.StreamResponse(status=client.status, headers=client.headers) + response = web.StreamResponse( + status=client.status, headers=_response_header(client, path) + ) response.content_type = client.content_type await response.prepare(request) @@ -148,11 +142,31 @@ def _init_header(request: web.Request) -> dict[str, str]: return headers -def _get_timeout(path: str) -> int: +def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: + """Create response header.""" + headers = {} + + for name, value in response.headers.items(): + if name in ( + TRANSFER_ENCODING, + CONTENT_LENGTH, + CONTENT_TYPE, + CONTENT_ENCODING, + ): + continue + headers[name] = value + + if NO_STORE.match(path): + headers[CACHE_CONTROL] = "no-store, max-age=0" + + return headers + + +def _get_timeout(path: str) -> ClientTimeout: """Return timeout for a URL path.""" if NO_TIMEOUT.match(path): - return 0 - return 300 + return ClientTimeout(connect=10, total=None) + return ClientTimeout(connect=10, total=300) def _need_auth(hass, path: str) -> bool: diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index e81980d78e1..55678eb29c4 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -1,15 +1,28 @@ """Sensor platform for Hass.io addons.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ADDONS_COORDINATOR -from .const import ATTR_VERSION, ATTR_VERSION_LATEST +from .const import ATTR_VERSION, ATTR_VERSION_LATEST, DATA_KEY_ADDONS, DATA_KEY_OS from .entity import HassioAddonEntity, HassioOSEntity +ENTITY_DESCRIPTIONS = ( + SensorEntityDescription( + entity_registry_enabled_default=False, + key=ATTR_VERSION, + name="Version", + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key=ATTR_VERSION_LATEST, + name="Newest Version", + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -21,16 +34,23 @@ async def async_setup_entry( entities = [] - for attribute_name, sensor_name in ( - (ATTR_VERSION, "Version"), - (ATTR_VERSION_LATEST, "Newest Version"), - ): - for addon in coordinator.data["addons"].values(): + for entity_description in ENTITY_DESCRIPTIONS: + for addon in coordinator.data[DATA_KEY_ADDONS].values(): entities.append( - HassioAddonSensor(coordinator, addon, attribute_name, sensor_name) + HassioAddonSensor( + addon=addon, + coordinator=coordinator, + entity_description=entity_description, + ) ) + if coordinator.is_hass_os: - entities.append(HassioOSSensor(coordinator, attribute_name, sensor_name)) + entities.append( + HassioOSSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) async_add_entities(entities) @@ -39,15 +59,17 @@ class HassioAddonSensor(HassioAddonEntity, SensorEntity): """Sensor to track a Hass.io add-on attribute.""" @property - def state(self) -> str: - """Return state of entity.""" - return self.addon_info[self.attribute_name] + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ + self.entity_description.key + ] class HassioOSSensor(HassioOSEntity, SensorEntity): """Sensor to track a Hass.io add-on attribute.""" @property - def state(self) -> str: - """Return state of entity.""" - return self.os_info[self.attribute_name] + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 0652b65d6e2..38d78984ddc 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -67,13 +67,13 @@ host_shutdown: description: Poweroff the host system. snapshot_full: - name: Create a full snapshot. - description: Create a full snapshot. + name: Create a full backup. + description: Create a full backup (deprecated, use backup_full instead). fields: name: name: Name description: Optional or it will be the current date and time. - example: "Snapshot 1" + example: "backup 1" selector: text: password: @@ -84,8 +84,8 @@ snapshot_full: text: snapshot_partial: - name: Create a partial snapshot. - description: Create a partial snapshot. + name: Create a partial backup. + description: Create a partial backup (deprecated, use backup_partial instead). fields: addons: name: Add-ons @@ -102,7 +102,53 @@ snapshot_partial: name: name: Name description: Optional or it will be the current date and time. - example: "Partial Snapshot 1" + example: "Partial backup 1" + selector: + text: + password: + name: Password + description: Optional password. + example: "password" + selector: + text: + +backup_full: + name: Create a full backup. + description: Create a full backup. + fields: + name: + name: Name + description: Optional or it will be the current date and time. + example: "backup 1" + selector: + text: + password: + name: Password + description: Optional password. + example: "password" + selector: + text: + +backup_partial: + name: Create a partial backup. + description: Create a partial backup. + fields: + addons: + name: Add-ons + description: Optional list of addon slugs. + example: ["core_ssh", "core_samba", "core_mosquitto"] + selector: + object: + folders: + name: Folders + description: Optional list of directories. + example: ["homeassistant", "share"] + selector: + object: + name: + name: Name + description: Optional or it will be the current date and time. + example: "Partial backup 1" selector: text: password: diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 55b369c2fde..738837989b9 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -69,12 +69,12 @@ class HaveIBeenPwnedSensor(SensorEntity): return f"Breaches {self._email}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 8169fa811e0..49d1c2f28fa 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -78,7 +78,7 @@ class HddTempSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -88,7 +88,7 @@ class HddTempSensor(SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 71826429040..87391634251 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -1,5 +1,6 @@ """Support for HDMI CEC.""" -from collections import defaultdict +from __future__ import annotations + from functools import partial, reduce import logging import multiprocessing @@ -44,6 +45,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery, event import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType DOMAIN = "hdmi_cec" @@ -66,8 +68,6 @@ ICONS_BY_TYPE = { 5: ICON_AUDIO, } -CEC_DEVICES = defaultdict(list) - CMD_UP = "up" CMD_DOWN = "down" CMD_MUTE = "mute" @@ -134,7 +134,7 @@ SERVICE_POWER_ON = "power_on" SERVICE_STANDBY = "standby" # pylint: disable=unnecessary-lambda -DEVICE_SCHEMA = vol.Schema( +DEVICE_SCHEMA: vol.Schema = vol.Schema( { vol.All(cv.positive_int): vol.Any( lambda devices: DEVICE_SCHEMA(devices), cv.string @@ -187,7 +187,7 @@ def parse_mapping(mapping, parents=None): yield (val, pad_physical_address(cur)) -def setup(hass: HomeAssistant, base_config): # noqa: C901 +def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 """Set up the CEC capability.""" # Parse configuration into a dict of device name to physical address @@ -372,13 +372,32 @@ def setup(hass: HomeAssistant, base_config): # noqa: C901 class CecEntity(Entity): """Representation of a HDMI CEC device entity.""" + _attr_should_poll = False + def __init__(self, device, logical) -> None: """Initialize the device.""" self._device = device - self._icon = None - self._state = None + self._state: str | None = None self._logical_address = logical self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) + self._set_attr_name() + if self._device.type in ICONS_BY_TYPE: + self._attr_icon = ICONS_BY_TYPE[self._device.type] + else: + self._attr_icon = ICON_UNKNOWN + + def _set_attr_name(self): + """Set name.""" + if ( + self._device.osd_name is not None + and self.vendor_name is not None + and self.vendor_name != "Unknown" + ): + self._attr_name = f"{self.vendor_name} {self._device.osd_name}" + elif self._device.osd_name is None: + self._attr_name = f"{self._device.type_name} {self._logical_address}" + else: + self._attr_name = f"{self._device.type_name} {self._logical_address} ({self._device.osd_name})" def _hdmi_cec_unavailable(self, callback_event): # Change state to unavailable. Without this, entity would remain in @@ -413,31 +432,6 @@ class CecEntity(Entity): """Device status changed, schedule an update.""" self.schedule_update_ha_state(True) - @property - def should_poll(self): - """ - Return false. - - CecEntity.update() is called by the HDMI network when there is new data. - """ - return False - - @property - def name(self): - """Return the name of the device.""" - return ( - f"{self.vendor_name} {self._device.osd_name}" - if ( - self._device.osd_name is not None - and self.vendor_name is not None - and self.vendor_name != "Unknown" - ) - else "%s %d" % (self._device.type_name, self._logical_address) - if self._device.osd_name is None - else "%s %d (%s)" - % (self._device.type_name, self._logical_address, self._device.osd_name) - ) - @property def vendor_id(self): """Return the ID of the device's vendor.""" @@ -463,17 +457,6 @@ class CecEntity(Entity): """Return the type ID of device.""" return self._device.type - @property - def icon(self): - """Return the icon for device by its type.""" - return ( - self._icon - if self._icon is not None - else ICONS_BY_TYPE.get(self._device.type) - if self._device.type in ICONS_BY_TYPE - else ICON_UNKNOWN - ) - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index c3cab6a8f98..1a5b3a6fc51 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -1,4 +1,6 @@ """Support for HDMI CEC devices as media players.""" +from __future__ import annotations + import logging from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand @@ -149,7 +151,7 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity): self.send_keypress(KEY_VOLUME_DOWN) @property - def state(self) -> str: + def state(self) -> str | None: """Cache state of device.""" return self._state diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index 5de38675fca..3764766275e 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -1,8 +1,9 @@ """Support for HDMI CEC devices as switches.""" +from __future__ import annotations + import logging from homeassistant.components.switch import DOMAIN, SwitchEntity -from homeassistant.const import STATE_OFF, STATE_ON from . import ATTR_NEW, CecEntity @@ -33,30 +34,17 @@ class CecSwitchEntity(CecEntity, SwitchEntity): def turn_on(self, **kwargs) -> None: """Turn device on.""" self._device.turn_on() - self._state = STATE_ON + self._attr_is_on = True self.schedule_update_ha_state(force_refresh=False) def turn_off(self, **kwargs) -> None: """Turn device off.""" self._device.turn_off() - self._state = STATE_OFF + self._attr_is_on = False self.schedule_update_ha_state(force_refresh=False) def toggle(self, **kwargs): """Toggle the entity.""" self._device.toggle() - if self._state == STATE_ON: - self._state = STATE_OFF - else: - self._state = STATE_ON + self._attr_is_on = not self._attr_is_on self.schedule_update_ha_state(force_refresh=False) - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._state == STATE_ON - - @property - def state(self) -> str: - """Return the cached state of device.""" - return self._state diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 7490c1e5be1..35520927e97 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -43,7 +43,7 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" if DOMAIN not in config: return True diff --git a/homeassistant/components/heos/translations/hu.json b/homeassistant/components/heos/translations/hu.json index 2fbce1993cd..c487b49ee47 100644 --- a/homeassistant/components/heos/translations/hu.json +++ b/homeassistant/components/heos/translations/hu.json @@ -10,7 +10,9 @@ "user": { "data": { "host": "Hoszt" - } + }, + "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).", + "title": "Csatlakoz\u00e1s a Heos-hoz" } } } diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 11fd19bd895..7606a2772d6 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -256,7 +256,7 @@ class HERETravelTimeSensor(SensorEntity): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" if self._here_data.traffic_mode and self._here_data.traffic_time is not None: return str(round(self._here_data.traffic_time / 60)) @@ -292,7 +292,7 @@ class HERETravelTimeSensor(SensorEntity): return res @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 3651dd8295f..518e555c280 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -167,7 +167,6 @@ async def ws_get_statistics_during_period( vol.Optional("statistic_type"): vol.Any("sum", "mean"), } ) -@websocket_api.require_admin @websocket_api.async_response async def ws_get_list_statistic_ids( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict @@ -393,7 +392,7 @@ class Filters: if includes and not excludes: return or_(*includes) - if not excludes and includes: + if not includes and excludes: return not_(or_(*excludes)) return or_(*includes) & not_(or_(*excludes)) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index e8ff9afc4e3..0db311b0354 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -153,7 +153,7 @@ class HistoryStatsSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.value is None or self.count is None: return None @@ -168,7 +168,7 @@ class HistoryStatsSensor(SensorEntity): return self.count @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index f21afc51801..5ea81bff123 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -57,7 +57,7 @@ class HiveSensorEntity(HiveEntity, SensorEntity): return DEVICETYPE[self.device["hiveType"]].get("type") @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return DEVICETYPE[self.device["hiveType"]].get("unit") @@ -67,7 +67,7 @@ class HiveSensorEntity(HiveEntity, SensorEntity): return self.device["haName"] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.device["status"]["state"] diff --git a/homeassistant/components/hive/translations/zh-Hans.json b/homeassistant/components/hive/translations/zh-Hans.json new file mode 100644 index 00000000000..780a47cb958 --- /dev/null +++ b/homeassistant/components/hive/translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "invalid_password": "\u65e0\u6cd5\u767b\u5f55 Hive\uff0c\u5bc6\u7801\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5\u3002" + }, + "step": { + "reauth": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index f8a9157dca2..1fc446af401 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import api, config_flow @@ -34,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["binary_sensor", "light", "sensor", "switch"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 463de6cda51..373ad6be295 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -42,7 +42,7 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): self._sign = sign @property - def state(self): + def native_value(self): """Return true if the binary sensor is on.""" return self._state @@ -83,7 +83,7 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): _LOGGER.debug("Updated, new state: %s", self._state) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index e775b9d97aa..ffb055e6324 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers import ( dispatcher, ) from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import config_flow, helpers @@ -50,7 +51,7 @@ PLATFORMS = ["switch"] _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Legrand Home+ Control component from configuration.yaml.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/home_plus_control/translations/en_GB.json b/homeassistant/components/home_plus_control/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index e798fda209b..2314d2b0c1b 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -14,17 +14,19 @@ from homeassistant.const import ( RESTART_EXIT_CODE, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, + SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) import homeassistant.core as ha from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser -from homeassistant.helpers import config_validation as cv, recorder +from homeassistant.helpers import config_validation as cv, recorder, restore_state from homeassistant.helpers.service import ( async_extract_config_entry_ids, async_extract_referenced_entity_ids, ) +from homeassistant.helpers.typing import ConfigType ATTR_ENTRY_ID = "entry_id" @@ -50,9 +52,13 @@ SCHEMA_RELOAD_CONFIG_ENTRY = vol.All( SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) -async def async_setup(hass: ha.HomeAssistant, config: dict) -> bool: # noqa: C901 +async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" + async def async_save_persistent_states(service): + """Handle calls to homeassistant.save_persistent_states.""" + await restore_state.RestoreStateData.async_save_persistent_states(hass) + async def async_handle_turn_service(service): """Handle calls to homeassistant.turn_on/off.""" referenced = await async_extract_referenced_entity_ids(hass, service) @@ -114,6 +120,10 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> bool: # noqa: C9 if tasks: await asyncio.gather(*tasks) + hass.services.async_register( + ha.DOMAIN, SERVICE_SAVE_PERSISTENT_STATES, async_save_persistent_states + ) + service_schema = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}, extra=vol.ALLOW_EXTRA) hass.services.async_register( diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 251ee171b6a..da52ff50d2f 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -74,3 +74,9 @@ reload_config_entry: example: 8955375327824e14ba89e4b29cc3ec9a selector: text: + +save_persistent_states: + name: Save Persistent States + description: + Save the persistent states (for entities derived from RestoreEntity) immediately. + Maintain the normal periodic saving interval. diff --git a/homeassistant/components/homeassistant/translations/ca.json b/homeassistant/components/homeassistant/translations/ca.json index 4e227cedadd..e9c91d9df20 100644 --- a/homeassistant/components/homeassistant/translations/ca.json +++ b/homeassistant/components/homeassistant/translations/ca.json @@ -10,6 +10,7 @@ "os_version": "Versi\u00f3 del sistema operatiu", "python_version": "Versi\u00f3 de Python", "timezone": "Zona hor\u00e0ria", + "user": "Usuari", "version": "Versi\u00f3", "virtualenv": "Entorn virtual" } diff --git a/homeassistant/components/homeassistant/translations/cs.json b/homeassistant/components/homeassistant/translations/cs.json index 0b60fb374bb..fbd96241e36 100644 --- a/homeassistant/components/homeassistant/translations/cs.json +++ b/homeassistant/components/homeassistant/translations/cs.json @@ -10,6 +10,7 @@ "os_version": "Verze opera\u010dn\u00edho syst\u00e9mu", "python_version": "Verze Pythonu", "timezone": "\u010casov\u00e9 p\u00e1smo", + "user": "U\u017eivatel", "version": "Verze", "virtualenv": "Virtu\u00e1ln\u00ed prost\u0159ed\u00ed" } diff --git a/homeassistant/components/homeassistant/translations/de.json b/homeassistant/components/homeassistant/translations/de.json index 426fab01031..54909cb3c24 100644 --- a/homeassistant/components/homeassistant/translations/de.json +++ b/homeassistant/components/homeassistant/translations/de.json @@ -10,6 +10,7 @@ "os_version": "Betriebssystem-Version", "python_version": "Python-Version", "timezone": "Zeitzone", + "user": "Benutzer", "version": "Version", "virtualenv": "Virtuelle Umgebung" } diff --git a/homeassistant/components/homeassistant/translations/en.json b/homeassistant/components/homeassistant/translations/en.json index 897b577c33c..977bc203fea 100644 --- a/homeassistant/components/homeassistant/translations/en.json +++ b/homeassistant/components/homeassistant/translations/en.json @@ -10,6 +10,7 @@ "os_version": "Operating System Version", "python_version": "Python Version", "timezone": "Timezone", + "user": "User", "version": "Version", "virtualenv": "Virtual Environment" } diff --git a/homeassistant/components/homeassistant/translations/es.json b/homeassistant/components/homeassistant/translations/es.json index 562a7335617..0a9342afa69 100644 --- a/homeassistant/components/homeassistant/translations/es.json +++ b/homeassistant/components/homeassistant/translations/es.json @@ -10,6 +10,7 @@ "os_version": "Versi\u00f3n del Sistema Operativo", "python_version": "Versi\u00f3n de Python", "timezone": "Zona horaria", + "user": "Usuario", "version": "Versi\u00f3n", "virtualenv": "Entorno virtual" } diff --git a/homeassistant/components/homeassistant/translations/et.json b/homeassistant/components/homeassistant/translations/et.json index fd53bf02877..529b84120d7 100644 --- a/homeassistant/components/homeassistant/translations/et.json +++ b/homeassistant/components/homeassistant/translations/et.json @@ -10,6 +10,7 @@ "os_version": "Operatsioonis\u00fcsteemi versioon", "python_version": "Pythoni versioon", "timezone": "Ajav\u00f6\u00f6nd", + "user": "Kasutaja", "version": "Versioon", "virtualenv": "Virtuaalne keskkond" } diff --git a/homeassistant/components/homeassistant/translations/fr.json b/homeassistant/components/homeassistant/translations/fr.json index 6b7d4f93559..ae9dfb0a7da 100644 --- a/homeassistant/components/homeassistant/translations/fr.json +++ b/homeassistant/components/homeassistant/translations/fr.json @@ -10,6 +10,7 @@ "os_version": "Version du syst\u00e8me d'exploitation", "python_version": "Version de Python", "timezone": "Fuseau horaire", + "user": "Utilisateur", "version": "Version", "virtualenv": "Environnement virtuel" } diff --git a/homeassistant/components/homeassistant/translations/he.json b/homeassistant/components/homeassistant/translations/he.json index f86d7b0dca0..20de5a2d1b7 100644 --- a/homeassistant/components/homeassistant/translations/he.json +++ b/homeassistant/components/homeassistant/translations/he.json @@ -8,6 +8,7 @@ "os_version": "\u05d2\u05d9\u05e8\u05e1\u05ea \u05de\u05e2\u05e8\u05db\u05ea \u05d4\u05e4\u05e2\u05dc\u05d4", "python_version": "\u05d2\u05e8\u05e1\u05ea \u05e4\u05d9\u05d9\u05ea\u05d5\u05df", "timezone": "\u05d0\u05d6\u05d5\u05e8 \u05d6\u05de\u05df", + "user": "\u05de\u05e9\u05ea\u05de\u05e9", "version": "\u05d2\u05d9\u05e8\u05e1\u05d4" } } diff --git a/homeassistant/components/homeassistant/translations/hu.json b/homeassistant/components/homeassistant/translations/hu.json index 9eddeeba112..b4da84596bf 100644 --- a/homeassistant/components/homeassistant/translations/hu.json +++ b/homeassistant/components/homeassistant/translations/hu.json @@ -10,6 +10,7 @@ "os_version": "Oper\u00e1ci\u00f3s rendszer verzi\u00f3ja", "python_version": "Python verzi\u00f3", "timezone": "Id\u0151z\u00f3na", + "user": "Felhaszn\u00e1l\u00f3", "version": "Verzi\u00f3", "virtualenv": "Virtu\u00e1lis k\u00f6rnyezet" } diff --git a/homeassistant/components/homeassistant/translations/it.json b/homeassistant/components/homeassistant/translations/it.json index 2d8d73597d3..3052a536338 100644 --- a/homeassistant/components/homeassistant/translations/it.json +++ b/homeassistant/components/homeassistant/translations/it.json @@ -10,6 +10,7 @@ "os_version": "Versione del Sistema Operativo", "python_version": "Versione Python", "timezone": "Fuso orario", + "user": "Utente", "version": "Versione", "virtualenv": "Ambiente virtuale" } diff --git a/homeassistant/components/homeassistant/translations/lt.json b/homeassistant/components/homeassistant/translations/lt.json new file mode 100644 index 00000000000..b1fd35bf9db --- /dev/null +++ b/homeassistant/components/homeassistant/translations/lt.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "user": "Vartotojas" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/nl.json b/homeassistant/components/homeassistant/translations/nl.json index 8c76ffa39be..1037d161c2b 100644 --- a/homeassistant/components/homeassistant/translations/nl.json +++ b/homeassistant/components/homeassistant/translations/nl.json @@ -10,6 +10,7 @@ "os_version": "Versie van het besturingssysteem", "python_version": "Python-versie", "timezone": "Tijdzone", + "user": "Gebruiker", "version": "Versie", "virtualenv": "Virtuele omgeving" } diff --git a/homeassistant/components/homeassistant/translations/no.json b/homeassistant/components/homeassistant/translations/no.json index 325bb53db15..675c02a6b66 100644 --- a/homeassistant/components/homeassistant/translations/no.json +++ b/homeassistant/components/homeassistant/translations/no.json @@ -10,6 +10,7 @@ "os_version": "Operativsystemversjon", "python_version": "Python versjon", "timezone": "Tidssone", + "user": "Bruker", "version": "Versjon", "virtualenv": "Virtuelt milj\u00f8" } diff --git a/homeassistant/components/homeassistant/translations/pl.json b/homeassistant/components/homeassistant/translations/pl.json index ea91096d0c2..9f85cc4ff15 100644 --- a/homeassistant/components/homeassistant/translations/pl.json +++ b/homeassistant/components/homeassistant/translations/pl.json @@ -10,6 +10,7 @@ "os_version": "Wersja systemu operacyjnego", "python_version": "Wersja Pythona", "timezone": "Strefa czasowa", + "user": "U\u017cytkownik", "version": "Wersja", "virtualenv": "\u015arodowisko wirtualne" } diff --git a/homeassistant/components/homeassistant/translations/ru.json b/homeassistant/components/homeassistant/translations/ru.json index c479fa41f43..f8932f1ea7d 100644 --- a/homeassistant/components/homeassistant/translations/ru.json +++ b/homeassistant/components/homeassistant/translations/ru.json @@ -10,6 +10,7 @@ "os_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b", "python_version": "\u0412\u0435\u0440\u0441\u0438\u044f Python", "timezone": "\u0427\u0430\u0441\u043e\u0432\u043e\u0439 \u043f\u043e\u044f\u0441", + "user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", "version": "\u0412\u0435\u0440\u0441\u0438\u044f", "virtualenv": "\u0412\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u043e\u0435 \u043e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0435" } diff --git a/homeassistant/components/homeassistant/translations/zh-Hans.json b/homeassistant/components/homeassistant/translations/zh-Hans.json index 617866926b8..e640d502e0c 100644 --- a/homeassistant/components/homeassistant/translations/zh-Hans.json +++ b/homeassistant/components/homeassistant/translations/zh-Hans.json @@ -10,6 +10,7 @@ "os_version": "\u64cd\u4f5c\u7cfb\u7edf\u7248\u672c", "python_version": "Python \u7248\u672c", "timezone": "\u65f6\u533a", + "user": "\u7528\u6237", "version": "\u7248\u672c", "virtualenv": "\u865a\u62df\u73af\u5883" } diff --git a/homeassistant/components/homeassistant/translations/zh-Hant.json b/homeassistant/components/homeassistant/translations/zh-Hant.json index 36f4fb70e24..21897b04560 100644 --- a/homeassistant/components/homeassistant/translations/zh-Hant.json +++ b/homeassistant/components/homeassistant/translations/zh-Hant.json @@ -10,6 +10,7 @@ "os_version": "\u4f5c\u696d\u7cfb\u7d71\u7248\u672c", "python_version": "Python \u7248\u672c", "timezone": "\u6642\u5340", + "user": "\u4f7f\u7528\u8005", "version": "\u7248\u672c", "virtualenv": "\u865b\u64ec\u74b0\u5883" } diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index f85c8ad5063..19298a9f814 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -8,7 +8,7 @@ from aiohttp import web from pyhap.const import STANDALONE_AID import voluptuous as vol -from homeassistant.components import network, zeroconf +from homeassistant.components import device_automation, network, zeroconf from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_MOTION, @@ -25,6 +25,10 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + CONF_DEVICES, CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, @@ -41,6 +45,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration from . import ( # noqa: F401 @@ -61,9 +66,6 @@ from .accessories import HomeBridge, HomeDriver, get_accessory from .aidmanager import AccessoryAidStorage from .const import ( ATTR_INTEGRATION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CONF_ADVERTISE_IP, @@ -98,6 +100,7 @@ from .const import ( SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) +from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, dismiss_setup_message, @@ -157,6 +160,7 @@ BRIDGE_SCHEMA = vol.All( vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, vol.Optional(CONF_ZEROCONF_DEFAULT_INTERFACE): cv.boolean, + vol.Optional(CONF_DEVICES): cv.ensure_list, }, extra=vol.ALLOW_EXTRA, ), @@ -187,7 +191,7 @@ def _async_get_entries_by_name(current_entries): return {entry.data.get(CONF_NAME, BRIDGE_NAME): entry for entry in current_entries} -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomeKit from yaml.""" hass.data.setdefault(DOMAIN, {}) @@ -236,8 +240,9 @@ def _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf): data = conf.copy() options = {} for key in CONFIG_OPTIONS: - options[key] = data[key] - del data[key] + if key in data: + options[key] = data[key] + del data[key] hass.config_entries.async_update_entry(entry, data=data, options=options) return True @@ -276,6 +281,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy() auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START) entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) + devices = options.get(CONF_DEVICES, []) homekit = HomeKit( hass, @@ -289,6 +295,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: advertise_ip, entry.entry_id, entry.title, + devices=devices, ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -491,6 +498,7 @@ class HomeKit: advertise_ip=None, entry_id=None, entry_title=None, + devices=None, ): """Initialize a HomeKit object.""" self.hass = hass @@ -504,6 +512,7 @@ class HomeKit: self._entry_id = entry_id self._entry_title = entry_title self._homekit_mode = homekit_mode + self._devices = devices or [] self.aid_storage = None self.status = STATUS_READY @@ -531,11 +540,6 @@ class HomeKit: # as pyhap uses a random one until state is restored if os.path.exists(persist_file): self.driver.load() - self.driver.state.config_version += 1 - if self.driver.state.config_version > 65535: - self.driver.state.config_version = 1 - - self.driver.persist() async def async_reset_accessories(self, entity_ids): """Reset the accessory to load the latest configuration.""" @@ -598,13 +602,7 @@ class HomeKit: def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" - # The bridge itself counts as an accessory - if len(self.bridge.accessories) + 1 >= MAX_DEVICES: - _LOGGER.warning( - "Cannot add %s as this would exceed the %d device limit. Consider using the filter option", - state.entity_id, - MAX_DEVICES, - ) + if self._would_exceed_max_devices(state.entity_id): return if state_needs_accessory_mode(state): @@ -635,6 +633,42 @@ class HomeKit: ) return None + def _would_exceed_max_devices(self, name): + """Check if adding another devices would reach the limit and log.""" + # The bridge itself counts as an accessory + if len(self.bridge.accessories) + 1 >= MAX_DEVICES: + _LOGGER.warning( + "Cannot add %s as this would exceed the %d device limit. Consider using the filter option", + name, + MAX_DEVICES, + ) + return True + return False + + def add_bridge_triggers_accessory(self, device, device_triggers): + """Add device automation triggers to the bridge.""" + if self._would_exceed_max_devices(device.name): + return + + aid = self.aid_storage.get_or_allocate_aid(device.id, device.id) + # If an accessory cannot be created or added due to an exception + # of any kind (usually in pyhap) it should not prevent + # the rest of the accessories from being created + config = {} + self._fill_config_from_device_registry_entry(device, config) + self.bridge.add_accessory( + DeviceTriggerAccessory( + self.hass, + self.driver, + device.name, + None, + aid, + config, + device_id=device.id, + device_triggers=device_triggers, + ) + ) + def remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" acc = self.bridge.accessories.pop(aid, None) @@ -687,6 +721,7 @@ class HomeKit: self._async_register_bridge() _LOGGER.debug("Driver start for %s", self._name) await self.driver.async_start() + self.driver.async_persist() self.status = STATUS_RUNNING if self.driver.state.paired: @@ -781,12 +816,31 @@ class HomeKit: ) return acc - @callback - def _async_create_bridge_accessory(self, entity_states): + async def _async_create_bridge_accessory(self, entity_states): """Create a HomeKit bridge with accessories. (bridge mode).""" self.bridge = HomeBridge(self.hass, self.driver, self._name) for state in entity_states: self.add_bridge_accessory(state) + dev_reg = device_registry.async_get(self.hass) + if self._devices: + valid_device_ids = [] + for device_id in self._devices: + if not dev_reg.async_get(device_id): + _LOGGER.warning( + "HomeKit %s cannot add device %s because it is missing from the device registry", + self._name, + device_id, + ) + else: + valid_device_ids.append(device_id) + for device_id, device_triggers in ( + await device_automation.async_get_device_automations( + self.hass, "trigger", valid_device_ids + ) + ).items(): + self.add_bridge_triggers_accessory( + dev_reg.async_get(device_id), device_triggers + ) return self.bridge async def _async_create_accessories(self): @@ -795,7 +849,7 @@ class HomeKit: if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: acc = self._async_create_single_accessory(entity_states) else: - acc = self._async_create_bridge_accessory(entity_states) + acc = await self._async_create_bridge_accessory(entity_states) if acc is None: return False @@ -878,15 +932,8 @@ class HomeKit: """Set attributes that will be used for homekit device info.""" ent_cfg = self._config.setdefault(entity_id, {}) if ent_reg_ent.device_id: - dev_reg_ent = dev_reg.async_get(ent_reg_ent.device_id) - if dev_reg_ent is not None: - # Handle missing devices - if dev_reg_ent.manufacturer: - ent_cfg[ATTR_MANUFACTURER] = dev_reg_ent.manufacturer - if dev_reg_ent.model: - ent_cfg[ATTR_MODEL] = dev_reg_ent.model - if dev_reg_ent.sw_version: - ent_cfg[ATTR_SOFTWARE_VERSION] = dev_reg_ent.sw_version + if dev_reg_ent := dev_reg.async_get(ent_reg_ent.device_id): + self._fill_config_from_device_registry_entry(dev_reg_ent, ent_cfg) if ATTR_MANUFACTURER not in ent_cfg: try: integration = await async_get_integration( @@ -896,6 +943,19 @@ class HomeKit: except IntegrationNotFound: ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform + def _fill_config_from_device_registry_entry(self, device_entry, config): + """Populate a config dict from the registry.""" + if device_entry.manufacturer: + config[ATTR_MANUFACTURER] = device_entry.manufacturer + if device_entry.model: + config[ATTR_MODEL] = device_entry.model + if device_entry.sw_version: + config[ATTR_SW_VERSION] = device_entry.sw_version + if device_entry.config_entries: + first_entry = list(device_entry.config_entries)[0] + if entry := self.hass.config_entries.async_get_entry(first_entry): + config[ATTR_INTEGRATION] = entry.domain + class HomeKitPairingQRView(HomeAssistantView): """Display the homekit pairing code at a protected url.""" diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 7edeb7179eb..8298cdd9c83 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -18,8 +18,11 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_SERVICE, ATTR_SUPPORTED_FEATURES, + ATTR_SW_VERSION, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, @@ -43,9 +46,6 @@ from homeassistant.util.decorator import Registry from .const import ( ATTR_DISPLAY_NAME, ATTR_INTEGRATION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, @@ -224,6 +224,7 @@ class HomeAccessory(Accessory): config, *args, category=CATEGORY_OTHER, + device_id=None, **kwargs, ): """Initialize a Accessory object.""" @@ -231,28 +232,39 @@ class HomeAccessory(Accessory): driver=driver, display_name=name[:MAX_NAME_LENGTH], aid=aid, *args, **kwargs ) self.config = config or {} - domain = split_entity_id(entity_id)[0].replace("_", " ") + if device_id: + self.device_id = device_id + serial_number = device_id + domain = None + else: + self.device_id = None + serial_number = entity_id + domain = split_entity_id(entity_id)[0].replace("_", " ") if self.config.get(ATTR_MANUFACTURER) is not None: manufacturer = self.config[ATTR_MANUFACTURER] elif self.config.get(ATTR_INTEGRATION) is not None: manufacturer = self.config[ATTR_INTEGRATION].replace("_", " ").title() - else: + elif domain: manufacturer = f"{MANUFACTURER} {domain}".title() + else: + manufacturer = MANUFACTURER if self.config.get(ATTR_MODEL) is not None: model = self.config[ATTR_MODEL] - else: + elif domain: model = domain.title() + else: + model = MANUFACTURER sw_version = None - if self.config.get(ATTR_SOFTWARE_VERSION) is not None: - sw_version = format_sw_version(self.config[ATTR_SOFTWARE_VERSION]) + if self.config.get(ATTR_SW_VERSION) is not None: + sw_version = format_sw_version(self.config[ATTR_SW_VERSION]) if sw_version is None: sw_version = __version__ self.set_info_service( manufacturer=manufacturer[:MAX_MANUFACTURER_LENGTH], model=model[:MAX_MODEL_LENGTH], - serial_number=entity_id[:MAX_SERIAL_LENGTH], + serial_number=serial_number[:MAX_SERIAL_LENGTH], firmware_revision=sw_version[:MAX_VERSION_LENGTH], ) @@ -260,6 +272,10 @@ class HomeAccessory(Accessory): self.entity_id = entity_id self.hass = hass self._subscriptions = [] + + if device_id: + return + self._char_battery = None self._char_charging = None self._char_low_battery = None @@ -528,9 +544,9 @@ class HomeDriver(AccessoryDriver): self._bridge_name = bridge_name self._entry_title = entry_title - def pair(self, client_uuid, client_public): + def pair(self, client_uuid, client_public, client_permissions): """Override super function to dismiss setup message if paired.""" - success = super().pair(client_uuid, client_public) + success = super().pair(client_uuid, client_public, client_permissions) if success: dismiss_setup_message(self.hass, self._entry_id) return success diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 5af5559b2ef..ddf3c7c564e 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -94,12 +94,12 @@ class AccessoryAidStorage: """Generate a stable aid for an entity id.""" entity = self._entity_registry.async_get(entity_id) if not entity: - return self._get_or_allocate_aid(None, entity_id) + return self.get_or_allocate_aid(None, entity_id) sys_unique_id = get_system_unique_id(entity) - return self._get_or_allocate_aid(sys_unique_id, entity_id) + return self.get_or_allocate_aid(sys_unique_id, entity_id) - def _get_or_allocate_aid(self, unique_id: str, entity_id: str): + def get_or_allocate_aid(self, unique_id: str, entity_id: str): """Allocate (and return) a new aid for an accessory.""" if unique_id and unique_id in self.allocations: return self.allocations[unique_id] diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 1ec53079179..03df55a9026 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -9,6 +9,7 @@ import string import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import device_automation from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN @@ -16,6 +17,7 @@ from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_FRIENDLY_NAME, + CONF_DEVICES, CONF_DOMAINS, CONF_ENTITIES, CONF_ENTITY_ID, @@ -23,6 +25,7 @@ from homeassistant.const import ( CONF_PORT, ) from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, @@ -318,20 +321,31 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if key in self.hk_options: del self.hk_options[key] + if ( + self.show_advanced_options + and self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE + ): + self.hk_options[CONF_DEVICES] = user_input[CONF_DEVICES] + return self.async_create_entry(title="", data=self.hk_options) + data_schema = { + vol.Optional( + CONF_AUTO_START, + default=self.hk_options.get(CONF_AUTO_START, DEFAULT_AUTO_START), + ): bool + } + + if self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE: + all_supported_devices = await _async_get_supported_devices(self.hass) + devices = self.hk_options.get(CONF_DEVICES, []) + data_schema[vol.Optional(CONF_DEVICES, default=devices)] = cv.multi_select( + all_supported_devices + ) + return self.async_show_form( step_id="advanced", - data_schema=vol.Schema( - { - vol.Optional( - CONF_AUTO_START, - default=self.hk_options.get( - CONF_AUTO_START, DEFAULT_AUTO_START - ), - ): bool - } - ), + data_schema=vol.Schema(data_schema), ) async def async_step_cameras(self, user_input=None): @@ -412,7 +426,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self.included_cameras = set() self.hk_options[CONF_FILTER] = entity_filter - if self.included_cameras: return await self.async_step_cameras() @@ -481,6 +494,17 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ) +async def _async_get_supported_devices(hass): + """Return all supported devices.""" + results = await device_automation.async_get_device_automations(hass, "trigger") + dev_reg = device_registry.async_get(hass) + unsorted = { + device_id: dev_reg.async_get(device_id).name or device_id + for device_id in results + } + return dict(sorted(unsorted.items(), key=lambda item: item[1])) + + def _async_get_matching_entities(hass, domains=None): """Fetch all entities or entities in the given domains.""" return { diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 7f413ef78df..4638c9f3b62 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,5 +1,7 @@ """Constants used be the HomeKit component.""" +from homeassistant.const import CONF_DEVICES + # #### Misc #### DEBOUNCE_TIMEOUT = 0.5 DEVICE_PRECISION_LEEWAY = 6 @@ -22,9 +24,6 @@ AUDIO_CODEC_COPY = "copy" ATTR_DISPLAY_NAME = "display_name" ATTR_VALUE = "value" ATTR_INTEGRATION = "platform" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" -ATTR_SOFTWARE_VERSION = "sw_version" ATTR_KEY_NAME = "key_name" # Current attribute used by homekit_controller ATTR_OBSTRUCTION_DETECTED = "obstruction-detected" @@ -139,6 +138,7 @@ SERV_MOTION_SENSOR = "MotionSensor" SERV_OCCUPANCY_SENSOR = "OccupancySensor" SERV_OUTLET = "Outlet" SERV_SECURITY_SYSTEM = "SecuritySystem" +SERV_SERVICE_LABEL = "ServiceLabel" SERV_SMOKE_SENSOR = "SmokeSensor" SERV_SPEAKER = "Speaker" SERV_STATELESS_PROGRAMMABLE_SWITCH = "StatelessProgrammableSwitch" @@ -208,6 +208,8 @@ CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" CHAR_SATURATION = "Saturation" CHAR_SERIAL_NUMBER = "SerialNumber" +CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex" +CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace" CHAR_SLEEP_DISCOVER_MODE = "SleepDiscoveryMode" CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" @@ -295,6 +297,7 @@ CONFIG_OPTIONS = [ CONF_SAFE_MODE, CONF_ENTITY_CONFIG, CONF_HOMEKIT_MODE, + CONF_DEVICES, ] # ### Maximum Lengths ### diff --git a/homeassistant/components/homekit/img_util.py b/homeassistant/components/homekit/img_util.py deleted file mode 100644 index 7d7a45081a6..00000000000 --- a/homeassistant/components/homekit/img_util.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Image processing for HomeKit component.""" - -import logging - -SUPPORTED_SCALING_FACTORS = [(7, 8), (3, 4), (5, 8), (1, 2), (3, 8), (1, 4), (1, 8)] - -_LOGGER = logging.getLogger(__name__) - - -def scale_jpeg_camera_image(cam_image, width, height): - """Scale a camera image as close as possible to one of the supported scaling factors.""" - turbo_jpeg = TurboJPEGSingleton.instance() - if not turbo_jpeg: - return cam_image.content - - (current_width, current_height, _, _) = turbo_jpeg.decode_header(cam_image.content) - - if current_width <= width or current_height <= height: - return cam_image.content - - ratio = width / current_width - - scaling_factor = SUPPORTED_SCALING_FACTORS[-1] - for supported_sf in SUPPORTED_SCALING_FACTORS: - if ratio >= (supported_sf[0] / supported_sf[1]): - scaling_factor = supported_sf - break - - return turbo_jpeg.scale_with_quality( - cam_image.content, - scaling_factor=scaling_factor, - quality=75, - ) - - -class TurboJPEGSingleton: - """ - Load TurboJPEG only once. - - Ensures we do not log load failures each snapshot - since camera image fetches happen every few - seconds. - """ - - __instance = None - - @staticmethod - def instance(): - """Singleton for TurboJPEG.""" - if TurboJPEGSingleton.__instance is None: - TurboJPEGSingleton() - return TurboJPEGSingleton.__instance - - def __init__(self): - """Try to create TurboJPEG only once.""" - # pylint: disable=unused-private-member - # https://github.com/PyCQA/pylint/issues/4681 - try: - # TurboJPEG checks for libturbojpeg - # when its created, but it imports - # numpy which may or may not work so - # we have to guard the import here. - from turbojpeg import TurboJPEG # pylint: disable=import-outside-toplevel - - TurboJPEGSingleton.__instance = TurboJPEG() - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Error loading libturbojpeg; Cameras may impact HomeKit performance" - ) - TurboJPEGSingleton.__instance = False diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 887dfc3ee37..e40d743068c 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,11 +3,10 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==3.6.0", + "HAP-python==4.1.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", - "base36==0.1.1", - "PyTurboJPEG==1.5.0" + "base36==0.1.1" ], "dependencies": ["http", "camera", "ffmpeg", "network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 3c9671c93e2..69cff3bfcc3 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -30,9 +30,10 @@ }, "advanced": { "data": { + "devices": "Devices (Triggers)", "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" }, - "description": "These settings only need to be adjusted if HomeKit is not functional.", + "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", "title": "Advanced Configuration" } } diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index cee1e64ad56..564709cb9c1 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -21,9 +21,10 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" + "auto_start": "Autostart (disable if you are calling the homekit.start service manually)", + "devices": "Devices (Triggers)" }, - "description": "These settings only need to be adjusted if HomeKit is not functional.", + "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", "title": "Advanced Configuration" }, "cameras": { diff --git a/homeassistant/components/homekit/translations/hu.json b/homeassistant/components/homekit/translations/hu.json index 1afc0183a0d..c6fdf0afd74 100644 --- a/homeassistant/components/homekit/translations/hu.json +++ b/homeassistant/components/homekit/translations/hu.json @@ -1,13 +1,18 @@ { "config": { + "abort": { + "port_name_in_use": "Az azonos nev\u0171 vagy port\u00fa tartoz\u00e9k vagy h\u00edd m\u00e1r konfigur\u00e1lva van." + }, "step": { "pairing": { + "description": "A p\u00e1ros\u00edt\u00e1s befejez\u00e9s\u00e9hez k\u00f6vesse a \u201eHomeKit p\u00e1ros\u00edt\u00e1s\u201d szakasz \u201e\u00c9rtes\u00edt\u00e9sek\u201d szakasz\u00e1ban tal\u00e1lhat\u00f3 utas\u00edt\u00e1sokat.", "title": "HomeKit p\u00e1ros\u00edt\u00e1s" }, "user": { "data": { "include_domains": "Felvenni k\u00edv\u00e1nt domainek" }, + "description": "V\u00e1lassza ki a felvenni k\u00edv\u00e1nt domaineket. A domain minden t\u00e1mogatott entit\u00e1sa szerepelni fog. Minden tartoz\u00e9k m\u00f3dban k\u00fcl\u00f6n HomeKit p\u00e9ld\u00e1ny j\u00f6n l\u00e9tre minden TV m\u00e9dialej\u00e1tsz\u00f3hoz, tev\u00e9kenys\u00e9g alap\u00fa t\u00e1vir\u00e1ny\u00edt\u00f3hoz, z\u00e1rhoz \u00e9s f\u00e9nyk\u00e9pez\u0151g\u00e9phez.", "title": "Felvenni k\u00edv\u00e1nt domainek kiv\u00e1laszt\u00e1sa" } } @@ -15,12 +20,17 @@ "options": { "step": { "advanced": { + "data": { + "auto_start": "Automatikus ind\u00edt\u00e1s (tiltsa le, ha manu\u00e1lisan h\u00edvja a homekit.start szolg\u00e1ltat\u00e1st)" + }, + "description": "Ezeket a be\u00e1ll\u00edt\u00e1sokat csak akkor kell m\u00f3dos\u00edtani, ha a HomeKit nem m\u0171k\u00f6dik.", "title": "Halad\u00f3 be\u00e1ll\u00edt\u00e1sok" }, "cameras": { "data": { "camera_copy": "A 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." }, "include_exclude": { @@ -28,6 +38,7 @@ "entities": "Entit\u00e1sok", "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" }, "init": { @@ -35,6 +46,7 @@ "include_domains": "Felvenni k\u00edv\u00e1nt domainek", "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." }, "yaml": { diff --git a/homeassistant/components/homekit/translations/nl.json b/homeassistant/components/homekit/translations/nl.json index b11038cc806..368005985bf 100644 --- a/homeassistant/components/homekit/translations/nl.json +++ b/homeassistant/components/homekit/translations/nl.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domeinen om op te nemen" }, - "description": "Kies de domeinen die moeten worden opgenomen. Alle ondersteunde entiteiten in het domein zullen worden opgenomen. Voor elke tv-mediaspeler en camera wordt een afzonderlijke HomeKit-instantie in accessoiremodus aangemaakt.", + "description": "Kies de domeinen die moeten worden opgenomen. Alle ondersteunde entiteiten in het domein zullen worden opgenomen. Voor elke tv-mediaspeler, camera, activiteiten gebaseerde afstandsbediening en slot wordt een afzonderlijke HomeKit-instantie in accessoiremodus aangemaakt.", "title": "Selecteer domeinen die u wilt opnemen" } } diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 7de5494c56a..2a4f1497e2f 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domener \u00e5 inkludere" }, - "description": "Velg domenene som skal inkluderes. Alle st\u00f8ttede enheter i domenet vil bli inkludert. Det opprettes en egen HomeKit-forekomst i tilbeh\u00f8rsmodus for hver tv-mediaspiller og kamera.", + "description": "Velg domenene som skal inkluderes. Alle enheter som st\u00f8ttes p\u00e5 domenet vil bli inkludert. En egen HomeKit -forekomst i tilbeh\u00f8rsmodus vil bli opprettet for hver tv -mediespiller, aktivitetsbasert fjernkontroll, l\u00e5s og kamera.", "title": "Velg domener som skal inkluderes" } } diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 077366870e2..4a8999ede08 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -55,7 +55,6 @@ from .const import ( SERV_SPEAKER, SERV_STATELESS_PROGRAMMABLE_SWITCH, ) -from .img_util import scale_jpeg_camera_image from .util import pid_is_alive _LOGGER = logging.getLogger(__name__) @@ -467,8 +466,9 @@ class Camera(HomeAccessory, PyhapCamera): async def async_get_snapshot(self, image_size): """Return a jpeg of a snapshot from the camera.""" - return scale_jpeg_camera_image( - await self.hass.components.camera.async_get_image(self.entity_id), - image_size["image-width"], - image_size["image-height"], + image = await self.hass.components.camera.async_get_image( + self.entity_id, + width=image_size["image-width"], + height=image_size["image-height"], ) + return image.content diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 099eced62d3..4c501208ca5 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -178,18 +178,11 @@ class GarageDoorOpener(HomeAccessory): obstruction_detected = ( new_state.attributes[ATTR_OBSTRUCTION_DETECTED] is True ) - if self.char_obstruction_detected.value != obstruction_detected: - self.char_obstruction_detected.set_value(obstruction_detected) + self.char_obstruction_detected.set_value(obstruction_detected) - if ( - target_door_state is not None - and self.char_target_state.value != target_door_state - ): + if target_door_state is not None: self.char_target_state.set_value(target_door_state) - if ( - current_door_state is not None - and self.char_current_state.value != current_door_state - ): + if current_door_state is not None: self.char_current_state.set_value(current_door_state) @@ -260,10 +253,8 @@ class OpeningDeviceBase(HomeAccessory): # We'll have to normalize to [0,100] current_tilt = (current_tilt / 100.0 * 180.0) - 90.0 current_tilt = int(current_tilt) - if self.char_current_tilt.value != current_tilt: - self.char_current_tilt.set_value(current_tilt) - if self.char_target_tilt.value != current_tilt: - self.char_target_tilt.set_value(current_tilt) + self.char_current_tilt.set_value(current_tilt) + self.char_target_tilt.set_value(current_tilt) class OpeningDevice(OpeningDeviceBase, HomeAccessory): @@ -312,14 +303,11 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, (float, int)): current_position = int(current_position) - if self.char_current_position.value != current_position: - self.char_current_position.set_value(current_position) - if self.char_target_position.value != current_position: - self.char_target_position.set_value(current_position) + self.char_current_position.set_value(current_position) + self.char_target_position.set_value(current_position) position_state = _hass_state_to_position_start(new_state.state) - if self.char_position_state.value != position_state: - self.char_position_state.set_value(position_state) + self.char_position_state.set_value(position_state) super().async_update_state(new_state) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 1a0bb41774c..85157dd9367 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -193,16 +193,14 @@ class Fan(HomeAccessory): state = new_state.state if state in (STATE_ON, STATE_OFF): self._state = 1 if state == STATE_ON else 0 - if self.char_active.value != self._state: - self.char_active.set_value(self._state) + self.char_active.set_value(self._state) # Handle Direction if self.char_direction is not None: direction = new_state.attributes.get(ATTR_DIRECTION) if direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): hk_direction = 1 if direction == DIRECTION_REVERSE else 0 - if self.char_direction.value != hk_direction: - self.char_direction.set_value(hk_direction) + self.char_direction.set_value(hk_direction) # Handle Speed if self.char_speed is not None and state != STATE_OFF: @@ -222,7 +220,7 @@ class Fan(HomeAccessory): # in order to avoid this incorrect behavior. if percentage == 0 and state == STATE_ON: percentage = 1 - if percentage is not None and self.char_speed.value != percentage: + if percentage is not None: self.char_speed.set_value(percentage) # Handle Oscillating @@ -230,11 +228,9 @@ class Fan(HomeAccessory): oscillating = new_state.attributes.get(ATTR_OSCILLATING) if isinstance(oscillating, bool): hk_oscillating = 1 if oscillating else 0 - if self.char_swing.value != hk_oscillating: - self.char_swing.set_value(hk_oscillating) + self.char_swing.set_value(hk_oscillating) current_preset_mode = new_state.attributes.get(ATTR_PRESET_MODE) for preset_mode, char in self.preset_mode_chars.items(): hk_value = 1 if preset_mode == current_preset_mode else 0 - if char.value != hk_value: - char.set_value(hk_value) + char.set_value(hk_value) diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index a4a73abf998..6371f883b09 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -224,8 +224,7 @@ class HumidifierDehumidifier(HomeAccessory): is_active = new_state.state == STATE_ON # Update active state - if self.char_active.value != is_active: - self.char_active.set_value(is_active) + self.char_active.set_value(is_active) # Set current state if is_active: @@ -235,13 +234,9 @@ class HumidifierDehumidifier(HomeAccessory): current_state = HC_STATE_DEHUMIDIFYING else: current_state = HC_STATE_INACTIVE - if self.char_current_humidifier_dehumidifier.value != current_state: - self.char_current_humidifier_dehumidifier.set_value(current_state) + self.char_current_humidifier_dehumidifier.set_value(current_state) # Update target humidity target_humidity = new_state.attributes.get(ATTR_HUMIDITY) - if ( - isinstance(target_humidity, (int, float)) - and self.char_target_humidity.value != target_humidity - ): + if isinstance(target_humidity, (int, float)): self.char_target_humidity.set_value(target_humidity) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 2a1256dfddb..90c55d52153 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -7,13 +7,11 @@ from pyhap.const import CATEGORY_LIGHTBULB from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, - COLOR_MODE_COLOR_TEMP, DOMAIN, brightness_supported, color_supported, @@ -26,13 +24,17 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback +from homeassistant.helpers.event import async_call_later +from homeassistant.util.color import ( + color_temperature_mired_to_kelvin, + color_temperature_to_hs, +) from .accessories import TYPES, HomeAccessory from .const import ( CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, CHAR_HUE, - CHAR_NAME, CHAR_ON, CHAR_SATURATION, PROP_MAX_VALUE, @@ -44,6 +46,8 @@ _LOGGER = logging.getLogger(__name__) RGB_COLOR = "rgb_color" +CHANGE_COALESCE_TIME_WINDOW = 0.01 + @TYPES.register("Light") class Light(HomeAccessory): @@ -56,102 +60,78 @@ class Light(HomeAccessory): """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) - self.chars_primary = [] - self.chars_secondary = [] + self.chars = [] + self._event_timer = None + self._pending_events = {} state = self.hass.states.get(self.entity_id) attributes = state.attributes color_modes = attributes.get(ATTR_SUPPORTED_COLOR_MODES) - self.is_color_supported = color_supported(color_modes) - self.is_color_temp_supported = color_temp_supported(color_modes) - self.color_and_temp_supported = ( - self.is_color_supported and self.is_color_temp_supported - ) - self.is_brightness_supported = brightness_supported(color_modes) + self.color_supported = color_supported(color_modes) + self.color_temp_supported = color_temp_supported(color_modes) + self.brightness_supported = brightness_supported(color_modes) - if self.is_brightness_supported: - self.chars_primary.append(CHAR_BRIGHTNESS) + if self.brightness_supported: + self.chars.append(CHAR_BRIGHTNESS) - if self.is_color_supported: - self.chars_primary.append(CHAR_HUE) - self.chars_primary.append(CHAR_SATURATION) + if self.color_supported: + self.chars.extend([CHAR_HUE, CHAR_SATURATION]) - if self.is_color_temp_supported: - if self.color_and_temp_supported: - self.chars_primary.append(CHAR_NAME) - self.chars_secondary.append(CHAR_NAME) - self.chars_secondary.append(CHAR_COLOR_TEMPERATURE) - if self.is_brightness_supported: - self.chars_secondary.append(CHAR_BRIGHTNESS) - else: - self.chars_primary.append(CHAR_COLOR_TEMPERATURE) + if self.color_temp_supported: + self.chars.append(CHAR_COLOR_TEMPERATURE) - serv_light_primary = self.add_preload_service( - SERV_LIGHTBULB, self.chars_primary - ) - serv_light_secondary = None - self.char_on_primary = serv_light_primary.configure_char(CHAR_ON, value=0) + serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars) + self.char_on = serv_light.configure_char(CHAR_ON, value=0) - if self.color_and_temp_supported: - serv_light_secondary = self.add_preload_service( - SERV_LIGHTBULB, self.chars_secondary - ) - serv_light_primary.add_linked_service(serv_light_secondary) - serv_light_primary.configure_char(CHAR_NAME, value="RGB") - self.char_on_secondary = serv_light_secondary.configure_char( - CHAR_ON, value=0 - ) - serv_light_secondary.configure_char(CHAR_NAME, value="Temperature") - - if self.is_brightness_supported: + if self.brightness_supported: # Initial value is set to 100 because 0 is a special value (off). 100 is # an arbitrary non-zero value. It is updated immediately by async_update_state # to set to the correct initial value. - self.char_brightness_primary = serv_light_primary.configure_char( - CHAR_BRIGHTNESS, value=100 - ) - if self.chars_secondary: - self.char_brightness_secondary = serv_light_secondary.configure_char( - CHAR_BRIGHTNESS, value=100 - ) + self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) - if self.is_color_temp_supported: + if self.color_temp_supported: min_mireds = math.floor(attributes.get(ATTR_MIN_MIREDS, 153)) max_mireds = math.ceil(attributes.get(ATTR_MAX_MIREDS, 500)) - serv_light = serv_light_secondary or serv_light_primary - self.char_color_temperature = serv_light.configure_char( + self.char_color_temp = serv_light.configure_char( CHAR_COLOR_TEMPERATURE, value=min_mireds, properties={PROP_MIN_VALUE: min_mireds, PROP_MAX_VALUE: max_mireds}, ) - if self.is_color_supported: - self.char_hue = serv_light_primary.configure_char(CHAR_HUE, value=0) - self.char_saturation = serv_light_primary.configure_char( - CHAR_SATURATION, value=75 - ) + if self.color_supported: + self.char_hue = serv_light.configure_char(CHAR_HUE, value=0) + self.char_saturation = serv_light.configure_char(CHAR_SATURATION, value=75) self.async_update_state(state) + serv_light.setter_callback = self._set_chars - if self.color_and_temp_supported: - serv_light_primary.setter_callback = self._set_chars_primary - serv_light_secondary.setter_callback = self._set_chars_secondary - else: - serv_light_primary.setter_callback = self._set_chars + def _set_chars(self, char_values): + _LOGGER.debug("Light _set_chars: %s", char_values) + # Newest change always wins + if CHAR_COLOR_TEMPERATURE in self._pending_events and ( + CHAR_SATURATION in char_values or CHAR_HUE in char_values + ): + del self._pending_events[CHAR_COLOR_TEMPERATURE] + for char in (CHAR_HUE, CHAR_SATURATION): + if char in self._pending_events and CHAR_COLOR_TEMPERATURE in char_values: + del self._pending_events[char] - def _set_chars_primary(self, char_values): - """Primary service is RGB or W if only color or color temp is supported.""" - self._set_chars(char_values, True) + self._pending_events.update(char_values) + if self._event_timer: + self._event_timer() + self._event_timer = async_call_later( + self.hass, CHANGE_COALESCE_TIME_WINDOW, self._send_events + ) - def _set_chars_secondary(self, char_values): - """Secondary service is W if both color or color temp are supported.""" - self._set_chars(char_values, False) - - def _set_chars(self, char_values, is_primary=None): - _LOGGER.debug("Light _set_chars: %s, is_primary: %s", char_values, is_primary) + def _send_events(self, *_): + """Process all changes at once.""" + _LOGGER.debug("Coalesced _set_chars: %s", self._pending_events) + char_values = self._pending_events + self._pending_events = {} events = [] service = SERVICE_TURN_ON params = {ATTR_ENTITY_ID: self.entity_id} + if CHAR_ON in char_values: if not char_values[CHAR_ON]: service = SERVICE_TURN_OFF @@ -171,24 +151,16 @@ class Light(HomeAccessory): ) return - if self.is_color_temp_supported and ( - is_primary is False or CHAR_COLOR_TEMPERATURE in char_values - ): - params[ATTR_COLOR_TEMP] = char_values.get( - CHAR_COLOR_TEMPERATURE, self.char_color_temperature.value - ) + if CHAR_COLOR_TEMPERATURE in char_values: + params[ATTR_COLOR_TEMP] = char_values[CHAR_COLOR_TEMPERATURE] events.append(f"color temperature at {params[ATTR_COLOR_TEMP]}") - if self.is_color_supported and ( - is_primary is True - or (CHAR_HUE in char_values and CHAR_SATURATION in char_values) - ): - color = ( + elif CHAR_HUE in char_values or CHAR_SATURATION in char_values: + color = params[ATTR_HS_COLOR] = ( char_values.get(CHAR_HUE, self.char_hue.value), char_values.get(CHAR_SATURATION, self.char_saturation.value), ) _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) - params[ATTR_HS_COLOR] = color events.append(f"set color at {color}") self.async_call_service(DOMAIN, service, params, ", ".join(events)) @@ -199,23 +171,10 @@ class Light(HomeAccessory): # Handle State state = new_state.state attributes = new_state.attributes - char_on_value = int(state == STATE_ON) - - if self.color_and_temp_supported: - color_mode = attributes.get(ATTR_COLOR_MODE) - color_temp_mode = color_mode == COLOR_MODE_COLOR_TEMP - primary_on_value = char_on_value if not color_temp_mode else 0 - secondary_on_value = char_on_value if color_temp_mode else 0 - if self.char_on_primary.value != primary_on_value: - self.char_on_primary.set_value(primary_on_value) - if self.char_on_secondary.value != secondary_on_value: - self.char_on_secondary.set_value(secondary_on_value) - else: - if self.char_on_primary.value != char_on_value: - self.char_on_primary.set_value(char_on_value) + self.char_on.set_value(int(state == STATE_ON)) # Handle Brightness - if self.is_brightness_supported: + if self.brightness_supported: brightness = attributes.get(ATTR_BRIGHTNESS) if isinstance(brightness, (int, float)): brightness = round(brightness / 255 * 100, 0) @@ -231,29 +190,25 @@ class Light(HomeAccessory): # order to avoid this incorrect behavior. if brightness == 0 and state == STATE_ON: brightness = 1 - if self.char_brightness_primary.value != brightness: - self.char_brightness_primary.set_value(brightness) - if ( - self.color_and_temp_supported - and self.char_brightness_secondary.value != brightness - ): - self.char_brightness_secondary.set_value(brightness) + self.char_brightness.set_value(brightness) + + # Handle Color - color must always be set before color temperature + # or the iOS UI will not display it correctly. + if self.color_supported: + if ATTR_COLOR_TEMP in attributes: + hue, saturation = color_temperature_to_hs( + color_temperature_mired_to_kelvin( + new_state.attributes[ATTR_COLOR_TEMP] + ) + ) + else: + hue, saturation = attributes.get(ATTR_HS_COLOR, (None, None)) + if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): + self.char_hue.set_value(round(hue, 0)) + self.char_saturation.set_value(round(saturation, 0)) # Handle color temperature - if self.is_color_temp_supported: - color_temperature = attributes.get(ATTR_COLOR_TEMP) - if isinstance(color_temperature, (int, float)): - color_temperature = round(color_temperature, 0) - if self.char_color_temperature.value != color_temperature: - self.char_color_temperature.set_value(color_temperature) - - # Handle Color - if self.is_color_supported: - hue, saturation = attributes.get(ATTR_HS_COLOR, (None, None)) - if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): - hue = round(hue, 0) - saturation = round(saturation, 0) - if hue != self.char_hue.value: - self.char_hue.set_value(hue) - if saturation != self.char_saturation.value: - self.char_saturation.set_value(saturation) + if self.color_temp_supported: + color_temp = attributes.get(ATTR_COLOR_TEMP) + if isinstance(color_temp, (int, float)): + self.char_color_temp.set_value(round(color_temp, 0)) diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 3a10a0a2f5a..af7501e1869 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -106,14 +106,10 @@ class Lock(HomeAccessory): # LockTargetState only supports locked and unlocked # Must set lock target state before current state # or there will be no notification - if ( - target_lock_state is not None - and self.char_target_state.value != target_lock_state - ): + if target_lock_state is not None: self.char_target_state.set_value(target_lock_state) # Set lock current state ONLY after ensuring that # target state is correct or there will be no # notification - if self.char_current_state.value != current_lock_state: - self.char_current_state.set_value(current_lock_state) + self.char_current_state.set_value(current_lock_state) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 081053d2591..7be1b98dcdb 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -180,8 +180,7 @@ class MediaPlayer(HomeAccessory): _LOGGER.debug( '%s: Set current state for "on_off" to %s', self.entity_id, hk_state ) - if self.chars[FEATURE_ON_OFF].value != hk_state: - self.chars[FEATURE_ON_OFF].set_value(hk_state) + self.chars[FEATURE_ON_OFF].set_value(hk_state) if self.chars[FEATURE_PLAY_PAUSE]: hk_state = current_state == STATE_PLAYING @@ -190,8 +189,7 @@ class MediaPlayer(HomeAccessory): self.entity_id, hk_state, ) - if self.chars[FEATURE_PLAY_PAUSE].value != hk_state: - self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) + self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) if self.chars[FEATURE_PLAY_STOP]: hk_state = current_state == STATE_PLAYING @@ -200,8 +198,7 @@ class MediaPlayer(HomeAccessory): self.entity_id, hk_state, ) - if self.chars[FEATURE_PLAY_STOP].value != hk_state: - self.chars[FEATURE_PLAY_STOP].set_value(hk_state) + self.chars[FEATURE_PLAY_STOP].set_value(hk_state) if self.chars[FEATURE_TOGGLE_MUTE]: current_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) @@ -210,8 +207,7 @@ class MediaPlayer(HomeAccessory): self.entity_id, current_state, ) - if self.chars[FEATURE_TOGGLE_MUTE].value != current_state: - self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) + self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) @TYPES.register("TelevisionMediaPlayer") @@ -341,8 +337,7 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): if current_state not in MEDIA_PLAYER_OFF_STATES: hk_state = 1 _LOGGER.debug("%s: Set current active state to %s", self.entity_id, hk_state) - if self.char_active.value != hk_state: - self.char_active.set_value(hk_state) + self.char_active.set_value(hk_state) # Set mute state if CHAR_VOLUME_SELECTOR in self.chars_speaker: @@ -352,7 +347,6 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): self.entity_id, current_mute_state, ) - if self.char_mute.value != current_mute_state: - self.char_mute.set_value(current_mute_state) + self.char_mute.set_value(current_mute_state) self._async_update_input_state(hk_state, new_state) diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 9e54221430c..53659adef77 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -154,8 +154,7 @@ class RemoteInputSelectAccessory(HomeAccessory): _LOGGER.debug("%s: Set current input to %s", self.entity_id, source_name) if source_name in self.sources: index = self.sources.index(source_name) - if self.char_input_source.value != index: - self.char_input_source.set_value(index) + self.char_input_source.set_value(index) return possible_sources = new_state.attributes.get(self.source_list_key, []) @@ -174,8 +173,7 @@ class RemoteInputSelectAccessory(HomeAccessory): source_name, possible_sources, ) - if self.char_input_source.value != 0: - self.char_input_source.set_value(0) + self.char_input_source.set_value(0) @TYPES.register("ActivityRemote") @@ -225,7 +223,6 @@ class ActivityRemote(RemoteInputSelectAccessory): # Power state remote hk_state = 1 if current_state == STATE_ON else 0 _LOGGER.debug("%s: Set current active state to %s", self.entity_id, hk_state) - if self.char_active.value != hk_state: - self.char_active.set_value(hk_state) + self.char_active.set_value(hk_state) self._async_update_input_state(hk_state, new_state) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 6fe1a4e9e29..d76fbf0f534 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -158,15 +158,12 @@ class SecuritySystem(HomeAccessory): """Update security state after state changed.""" hass_state = new_state.state if (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None: - if self.char_current_state.value != current_state: - self.char_current_state.set_value(current_state) - _LOGGER.debug( - "%s: Updated current state to %s (%d)", - self.entity_id, - hass_state, - current_state, - ) - + self.char_current_state.set_value(current_state) + _LOGGER.debug( + "%s: Updated current state to %s (%d)", + self.entity_id, + hass_state, + current_state, + ) if (target_state := HASS_TO_HOMEKIT_TARGET.get(hass_state)) is not None: - if self.char_target_state.value != target_state: - self.char_target_state.set_value(target_state) + self.char_target_state.set_value(target_state) diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index b6cc4b05125..bcef7564fa3 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -101,11 +101,10 @@ class TemperatureSensor(HomeAccessory): temperature = convert_to_float(new_state.state) if temperature: temperature = temperature_to_homekit(temperature, unit) - if self.char_temp.value != temperature: - self.char_temp.set_value(temperature) - _LOGGER.debug( - "%s: Current temperature set to %.1f°C", self.entity_id, temperature - ) + self.char_temp.set_value(temperature) + _LOGGER.debug( + "%s: Current temperature set to %.1f°C", self.entity_id, temperature + ) @TYPES.register("HumiditySensor") @@ -128,7 +127,7 @@ class HumiditySensor(HomeAccessory): def async_update_state(self, new_state): """Update accessory after state change.""" humidity = convert_to_float(new_state.state) - if humidity and self.char_humidity.value != humidity: + if humidity: self.char_humidity.set_value(humidity) _LOGGER.debug("%s: Percent set to %d%%", self.entity_id, humidity) @@ -161,9 +160,8 @@ class AirQualitySensor(HomeAccessory): self.char_density.set_value(density) _LOGGER.debug("%s: Set density to %d", self.entity_id, density) air_quality = density_to_air_quality(density) - if self.char_quality.value != air_quality: - self.char_quality.set_value(air_quality) - _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + self.char_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) @TYPES.register("CarbonMonoxideSensor") @@ -194,14 +192,12 @@ class CarbonMonoxideSensor(HomeAccessory): """Update accessory after state change.""" value = convert_to_float(new_state.state) if value: - if self.char_level.value != value: - self.char_level.set_value(value) + self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) co_detected = value > THRESHOLD_CO - if self.char_detected.value is not co_detected: - self.char_detected.set_value(co_detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, value) + self.char_detected.set_value(co_detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) @TYPES.register("CarbonDioxideSensor") @@ -232,14 +228,12 @@ class CarbonDioxideSensor(HomeAccessory): """Update accessory after state change.""" value = convert_to_float(new_state.state) if value: - if self.char_level.value != value: - self.char_level.set_value(value) + self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) co2_detected = value > THRESHOLD_CO2 - if self.char_detected.value is not co2_detected: - self.char_detected.set_value(co2_detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, value) + self.char_detected.set_value(co2_detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) @TYPES.register("LightSensor") @@ -262,7 +256,7 @@ class LightSensor(HomeAccessory): def async_update_state(self, new_state): """Update accessory after state change.""" luminance = convert_to_float(new_state.state) - if luminance and self.char_light.value != luminance: + if luminance: self.char_light.set_value(luminance) _LOGGER.debug("%s: Set to %d", self.entity_id, luminance) @@ -297,6 +291,5 @@ class BinarySensor(HomeAccessory): """Update accessory after state change.""" state = new_state.state detected = self.format(state in (STATE_ON, STATE_HOME)) - if self.char_detected.value != detected: - self.char_detected.set_value(detected) - _LOGGER.debug("%s: Set to %d", self.entity_id, detected) + self.char_detected.set_value(detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, detected) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index ef9dadff287..3bb496a2abc 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -91,9 +91,8 @@ class Outlet(HomeAccessory): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = new_state.state == STATE_ON - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Switch") @@ -123,8 +122,7 @@ class Switch(HomeAccessory): def reset_switch(self, *args): """Reset switch to emulate activate click.""" _LOGGER.debug("%s: Reset switch to off", self.entity_id) - if self.char_on.value is not False: - self.char_on.set_value(False) + self.char_on.set_value(False) def set_state(self, value): """Move switch state to value if call came from HomeKit.""" @@ -156,9 +154,8 @@ class Switch(HomeAccessory): return current_state = new_state.state == STATE_ON - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Vacuum") @@ -186,9 +183,8 @@ class Vacuum(Switch): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = new_state.state in (STATE_CLEANING, STATE_ON) - if self.char_on.value is not current_state: - _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) - self.char_on.set_value(current_state) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) @TYPES.register("Valve") @@ -226,9 +222,7 @@ class Valve(HomeAccessory): def async_update_state(self, new_state): """Update switch state after state changed.""" current_state = 1 if new_state.state == STATE_ON else 0 - if self.char_active.value != current_state: - _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) - self.char_active.set_value(current_state) - if self.char_in_use.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) + _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) + 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) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index fb3063704c2..c36a32b0d5b 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -446,8 +446,7 @@ class Thermostat(HomeAccessory): if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] if homekit_hvac_mode in self.hc_homekit_to_hass: - if self.char_target_heat_cool.value != homekit_hvac_mode: - self.char_target_heat_cool.set_value(homekit_hvac_mode) + self.char_target_heat_cool.set_value(homekit_hvac_mode) else: _LOGGER.error( "Cannot map hvac target mode: %s to homekit as only %s modes are supported", @@ -459,30 +458,23 @@ class Thermostat(HomeAccessory): hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION) if hvac_action: homekit_hvac_action = HC_HASS_TO_HOMEKIT_ACTION[hvac_action] - if self.char_current_heat_cool.value != homekit_hvac_action: - self.char_current_heat_cool.set_value(homekit_hvac_action) + self.char_current_heat_cool.set_value(homekit_hvac_action) # Update current temperature current_temp = _get_current_temperature(new_state, self._unit) - if current_temp is not None and self.char_current_temp.value != current_temp: + if current_temp is not None: self.char_current_temp.set_value(current_temp) # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: current_humdity = new_state.attributes.get(ATTR_CURRENT_HUMIDITY) - if ( - isinstance(current_humdity, (int, float)) - and self.char_current_humidity.value != current_humdity - ): + if isinstance(current_humdity, (int, float)): self.char_current_humidity.set_value(current_humdity) # Update target humidity if CHAR_TARGET_HUMIDITY in self.chars: target_humdity = new_state.attributes.get(ATTR_HUMIDITY) - if ( - isinstance(target_humdity, (int, float)) - and self.char_target_humidity.value != target_humdity - ): + if isinstance(target_humdity, (int, float)): self.char_target_humidity.set_value(target_humdity) # Update cooling threshold temperature if characteristic exists @@ -490,16 +482,14 @@ class Thermostat(HomeAccessory): cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(cooling_thresh, (int, float)): cooling_thresh = self._temperature_to_homekit(cooling_thresh) - if self.char_heating_thresh_temp.value != cooling_thresh: - self.char_cooling_thresh_temp.set_value(cooling_thresh) + self.char_cooling_thresh_temp.set_value(cooling_thresh) # Update heating threshold temperature if characteristic exists if self.char_heating_thresh_temp: heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) if isinstance(heating_thresh, (int, float)): heating_thresh = self._temperature_to_homekit(heating_thresh) - if self.char_heating_thresh_temp.value != heating_thresh: - self.char_heating_thresh_temp.set_value(heating_thresh) + self.char_heating_thresh_temp.set_value(heating_thresh) # Update target temperature target_temp = _get_target_temperature(new_state, self._unit) @@ -515,14 +505,13 @@ class Thermostat(HomeAccessory): temp_high = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(temp_high, (int, float)): target_temp = self._temperature_to_homekit(temp_high) - if target_temp and self.char_target_temp.value != target_temp: + if target_temp: self.char_target_temp.set_value(target_temp) # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: unit = UNIT_HASS_TO_HOMEKIT[self._unit] - if self.char_display_units.value != unit: - self.char_display_units.set_value(unit) + self.char_display_units.set_value(unit) @TYPES.register("WaterHeater") @@ -580,7 +569,7 @@ class WaterHeater(HomeAccessory): """Change operation mode to value if call came from HomeKit.""" _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) hass_value = HC_HOMEKIT_TO_HASS[value] - if hass_value != HVAC_MODE_HEAT and self.char_target_heat_cool.value != 1: + if hass_value != HVAC_MODE_HEAT: self.char_target_heat_cool.set_value(1) # Heat def set_target_temperature(self, value): @@ -600,28 +589,21 @@ class WaterHeater(HomeAccessory): """Update water_heater state after state change.""" # Update current and target temperature target_temperature = _get_target_temperature(new_state, self._unit) - if ( - target_temperature is not None - and target_temperature != self.char_target_temp.value - ): + if target_temperature is not None: self.char_target_temp.set_value(target_temperature) current_temperature = _get_current_temperature(new_state, self._unit) - if ( - current_temperature is not None - and current_temperature != self.char_current_temp.value - ): + if current_temperature is not None: self.char_current_temp.set_value(current_temperature) # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: unit = UNIT_HASS_TO_HOMEKIT[self._unit] - if self.char_display_units.value != unit: - self.char_display_units.set_value(unit) + self.char_display_units.set_value(unit) # Update target operation mode operation_mode = new_state.state - if operation_mode and self.char_target_heat_cool.value != 1: + if operation_mode: self.char_target_heat_cool.set_value(1) # Heat diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py new file mode 100644 index 00000000000..6d5f67f9915 --- /dev/null +++ b/homeassistant/components/homekit/type_triggers.py @@ -0,0 +1,89 @@ +"""Class to hold all sensor accessories.""" +import logging + +from pyhap.const import CATEGORY_SENSOR + +from homeassistant.helpers.trigger import async_initialize_triggers + +from .accessories import TYPES, HomeAccessory +from .const import ( + CHAR_NAME, + CHAR_PROGRAMMABLE_SWITCH_EVENT, + CHAR_SERVICE_LABEL_INDEX, + CHAR_SERVICE_LABEL_NAMESPACE, + SERV_SERVICE_LABEL, + SERV_STATELESS_PROGRAMMABLE_SWITCH, +) + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register("DeviceTriggerAccessory") +class DeviceTriggerAccessory(HomeAccessory): + """Generate a Programmable switch.""" + + def __init__(self, *args, device_triggers=None, device_id=None): + """Initialize a Programmable switch accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR, device_id=device_id) + self._device_triggers = device_triggers + self._remove_triggers = None + self.triggers = [] + for idx, trigger in enumerate(device_triggers): + type_ = trigger.get("type") + subtype = trigger.get("subtype") + trigger_name = ( + f"{type_.title()} {subtype.title()}" if subtype else type_.title() + ) + serv_stateless_switch = self.add_preload_service( + SERV_STATELESS_PROGRAMMABLE_SWITCH, + [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX], + ) + self.triggers.append( + serv_stateless_switch.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, + value=0, + valid_values={"Trigger": 0}, + ) + ) + serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name) + serv_stateless_switch.configure_char( + CHAR_SERVICE_LABEL_INDEX, value=idx + 1 + ) + serv_service_label = self.add_preload_service(SERV_SERVICE_LABEL) + serv_service_label.configure_char(CHAR_SERVICE_LABEL_NAMESPACE, value=1) + serv_stateless_switch.add_linked_service(serv_service_label) + + async def async_trigger(self, run_variables, context=None, skip_condition=False): + """Trigger button press. + + This method is a coroutine. + """ + reason = "" + if "trigger" in run_variables and "description" in run_variables["trigger"]: + reason = f' by {run_variables["trigger"]["description"]}' + _LOGGER.debug("Button triggered%s - %s", reason, run_variables) + idx = int(run_variables["trigger"]["idx"]) + self.triggers[idx].set_value(0) + + # Attach the trigger using the helper in async run + # and detach it in async stop + async def run(self): + """Handle accessory driver started event.""" + self._remove_triggers = await async_initialize_triggers( + self.hass, + self._device_triggers, + self.async_trigger, + "homekit", + self.display_name, + _LOGGER.log, + ) + + async def stop(self): + """Handle accessory driver stop event.""" + if self._remove_triggers: + self._remove_triggers() + + @property + def available(self): + """Return available.""" + return True diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index c14cfbb8a7e..f7c98c66708 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -142,7 +142,7 @@ class HomeKitEntity(Entity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.available + return self._accessory.available and self.service.available @property def device_info(self): diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py index 2a162eb2b2a..b4ca2f4918a 100644 --- a/homeassistant/components/homekit_controller/air_quality.py +++ b/homeassistant/components/homekit_controller/air_quality.py @@ -1,4 +1,6 @@ """Support for HomeKit Controller air quality sensors.""" +import logging + from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes @@ -7,6 +9,8 @@ from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity +_LOGGER = logging.getLogger(__name__) + AIR_QUALITY_TEXT = { 0: "unknown", 1: "excellent", @@ -20,6 +24,20 @@ AIR_QUALITY_TEXT = { class HomeAirQualitySensor(HomeKitEntity, AirQualityEntity): """Representation of a HomeKit Controller Air Quality sensor.""" + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + _LOGGER.warning( + "The homekit_controller air_quality entity has been " + "deprecated and will be removed in 2021.12.0" + ) + await super().async_added_to_hass() + + @property + def entity_registry_enabled_default(self) -> bool: + """Whether or not to enable this entity by default.""" + # This entity is deprecated, so don't enable by default + return False + def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index fc6a5bb4522..a0b15087356 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -1,4 +1,6 @@ """Support for Homekit cameras.""" +from __future__ import annotations + from aiohomekit.model.services import ServicesTypes from homeassistant.components.camera import Camera @@ -21,12 +23,14 @@ class HomeKitCamera(AccessoryEntity, Camera): """Return the current state of the camera.""" return "idle" - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a jpeg with the current camera snapshot.""" return await self._accessory.pairing.image( self._aid, - 640, - 480, + width or 640, + height or 480, ) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 5b4c87f53e4..fa28bab7606 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -45,11 +45,28 @@ HOMEKIT_ACCESSORY_DISPATCH = { CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.Vendor.EVE_ENERGY_WATT: "sensor", + CharacteristicsTypes.Vendor.EVE_DEGREE_AIR_PRESSURE: "sensor", + CharacteristicsTypes.Vendor.EVE_DEGREE_ELEVATION: "number", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: "sensor", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: "sensor", CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: "number", - CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): "sensor", - CharacteristicsTypes.get_uuid( - CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT - ): "sensor", + CharacteristicsTypes.TEMPERATURE_CURRENT: "sensor", + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: "sensor", + CharacteristicsTypes.AIR_QUALITY: "sensor", + CharacteristicsTypes.DENSITY_PM25: "sensor", + CharacteristicsTypes.DENSITY_PM10: "sensor", + CharacteristicsTypes.DENSITY_OZONE: "sensor", + CharacteristicsTypes.DENSITY_NO2: "sensor", + CharacteristicsTypes.DENSITY_SO2: "sensor", + CharacteristicsTypes.DENSITY_VOC: "sensor", } + +# For legacy reasons, "built-in" characteristic types are in their short form +# And vendor types don't have a short form +# This means long and short forms get mixed up in this dict, and comparisons +# don't work! +# We call get_uuid on *every* type to normalise them to the long form +# Eventually aiohomekit will use the long form exclusively amd this can be removed. +for k, v in list(CHARACTERISTIC_PLATFORMS.items()): + value = CHARACTERISTIC_PLATFORMS.pop(k) + CHARACTERISTIC_PLATFORMS[CharacteristicsTypes.get_uuid(k)] = value diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 818b75e47d3..1972aadfeca 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for homekit devices.""" from __future__ import annotations +from typing import Any + from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics.const import InputEventValues from aiohomekit.model.services import ServicesTypes @@ -232,7 +234,9 @@ def async_fire_triggers(conn, events): source.fire(iid, ev) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for homekit devices.""" if device_id not in hass.data.get(TRIGGERS, {}): diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index a4644d0e34a..442db645c1f 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.0"], + "requirements": ["aiohomekit==0.6.2"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index 73d8cd6adbd..79130bfcef7 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -15,7 +15,11 @@ NUMBER_ENTITIES = { CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: { "name": "Spray Quantity", "icon": "mdi:water", - } + }, + CharacteristicsTypes.Vendor.EVE_DEGREE_ELEVATION: { + "name": "Elevation", + "icon": "mdi:elevation-rise", + }, } diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 91b62b0d572..cac61d59ac4 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -4,15 +4,25 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, LIGHT_LUX, PERCENTAGE, POWER_WATT, + PRESSURE_HPA, TEMP_CELSIUS, ) from homeassistant.core import callback @@ -44,7 +54,13 @@ SIMPLE_SENSOR = { "state_class": STATE_CLASS_MEASUREMENT, "unit": POWER_WATT, }, - CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): { + CharacteristicsTypes.Vendor.EVE_DEGREE_AIR_PRESSURE: { + "name": "Air Pressure", + "device_class": DEVICE_CLASS_PRESSURE, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": PRESSURE_HPA, + }, + CharacteristicsTypes.TEMPERATURE_CURRENT: { "name": "Current Temperature", "device_class": DEVICE_CLASS_TEMPERATURE, "state_class": STATE_CLASS_MEASUREMENT, @@ -54,7 +70,7 @@ SIMPLE_SENSOR = { "probe": lambda char: char.service.type != ServicesTypes.get_uuid(ServicesTypes.TEMPERATURE_SENSOR), }, - CharacteristicsTypes.get_uuid(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT): { + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: { "name": "Current Humidity", "device_class": DEVICE_CLASS_HUMIDITY, "state_class": STATE_CLASS_MEASUREMENT, @@ -64,14 +80,64 @@ SIMPLE_SENSOR = { "probe": lambda char: char.service.type != ServicesTypes.get_uuid(ServicesTypes.HUMIDITY_SENSOR), }, + CharacteristicsTypes.AIR_QUALITY: { + "name": "Air Quality", + "device_class": DEVICE_CLASS_AQI, + "state_class": STATE_CLASS_MEASUREMENT, + }, + CharacteristicsTypes.DENSITY_PM25: { + "name": "PM2.5 Density", + "device_class": DEVICE_CLASS_PM25, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_PM10: { + "name": "PM10 Density", + "device_class": DEVICE_CLASS_PM10, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_OZONE: { + "name": "Ozone Density", + "device_class": DEVICE_CLASS_OZONE, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_NO2: { + "name": "Nitrogen Dioxide Density", + "device_class": DEVICE_CLASS_NITROGEN_DIOXIDE, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_SO2: { + "name": "Sulphur Dioxide Density", + "device_class": DEVICE_CLASS_SULPHUR_DIOXIDE, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + CharacteristicsTypes.DENSITY_VOC: { + "name": "Volatile Organic Compound Density", + "device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, } +# For legacy reasons, "built-in" characteristic types are in their short form +# And vendor types don't have a short form +# This means long and short forms get mixed up in this dict, and comparisons +# don't work! +# We call get_uuid on *every* type to normalise them to the long form +# Eventually aiohomekit will use the long form exclusively amd this can be removed. +for k, v in list(SIMPLE_SENSOR.items()): + SIMPLE_SENSOR[CharacteristicsTypes.get_uuid(k)] = SIMPLE_SENSOR.pop(k) + class HomeKitHumiditySensor(HomeKitEntity, SensorEntity): """Representation of a Homekit humidity sensor.""" _attr_device_class = DEVICE_CLASS_HUMIDITY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -88,7 +154,7 @@ class HomeKitHumiditySensor(HomeKitEntity, SensorEntity): return HUMIDITY_ICON @property - def state(self): + def native_value(self): """Return the current humidity.""" return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) @@ -97,7 +163,7 @@ class HomeKitTemperatureSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -114,7 +180,7 @@ class HomeKitTemperatureSensor(HomeKitEntity, SensorEntity): return TEMP_C_ICON @property - def state(self): + def native_value(self): """Return the current temperature in Celsius.""" return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) @@ -123,7 +189,7 @@ class HomeKitLightSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit light level sensor.""" _attr_device_class = DEVICE_CLASS_ILLUMINANCE - _attr_unit_of_measurement = LIGHT_LUX + _attr_native_unit_of_measurement = LIGHT_LUX def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -140,7 +206,7 @@ class HomeKitLightSensor(HomeKitEntity, SensorEntity): return BRIGHTNESS_ICON @property - def state(self): + def native_value(self): """Return the current light level in lux.""" return self.service.value(CharacteristicsTypes.LIGHT_LEVEL_CURRENT) @@ -149,7 +215,7 @@ class HomeKitCarbonDioxideSensor(HomeKitEntity, SensorEntity): """Representation of a Homekit Carbon Dioxide sensor.""" _attr_icon = CO2_ICON - _attr_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -161,7 +227,7 @@ class HomeKitCarbonDioxideSensor(HomeKitEntity, SensorEntity): return f"{super().name} CO2" @property - def state(self): + def native_value(self): """Return the current CO2 level in ppm.""" return self.service.value(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL) @@ -170,7 +236,7 @@ class HomeKitBatterySensor(HomeKitEntity, SensorEntity): """Representation of a Homekit battery sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" @@ -221,7 +287,7 @@ class HomeKitBatterySensor(HomeKitEntity, SensorEntity): return self.service.value(CharacteristicsTypes.CHARGING_STATE) == 1 @property - def state(self): + def native_value(self): """Return the current battery level percentage.""" return self.service.value(CharacteristicsTypes.BATTERY_LEVEL) @@ -273,7 +339,7 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): return self._state_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return units for the sensor.""" return self._unit @@ -288,7 +354,7 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): return f"{super().name} - {self._name}" @property - def state(self): + def native_value(self): """Return the current sensor value.""" return self._char.value diff --git a/homeassistant/components/homekit_controller/translations/hu.json b/homeassistant/components/homekit_controller/translations/hu.json index cd06d12e809..1ad63bfb508 100644 --- a/homeassistant/components/homekit_controller/translations/hu.json +++ b/homeassistant/components/homekit_controller/translations/hu.json @@ -21,6 +21,7 @@ "flow_title": "HomeKit tartoz\u00e9k: {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.", "title": "Az eszk\u00f6z m\u00e1r p\u00e1rosul egy m\u00e1sik vez\u00e9rl\u0151vel" }, "max_tries_error": { @@ -36,6 +37,7 @@ "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" }, "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.", "title": "Hiba t\u00f6rt\u00e9nt a tartoz\u00e9kkal val\u00f3 kommunik\u00e1ci\u00f3 sor\u00e1n" }, "user": { diff --git a/homeassistant/components/homekit_controller/translations/lt.json b/homeassistant/components/homekit_controller/translations/lt.json new file mode 100644 index 00000000000..965b32b366d --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/lt.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u012erenginio pasirinkimas" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/zh-Hans.json b/homeassistant/components/homekit_controller/translations/zh-Hans.json index 624050e7146..7da392179f6 100644 --- a/homeassistant/components/homekit_controller/translations/zh-Hans.json +++ b/homeassistant/components/homekit_controller/translations/zh-Hans.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "HomeKit \u4ee3\u7801\u4e0d\u6b63\u786e\u3002\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5\u3002", + "insecure_setup_code": "\u8bf7\u6c42\u7684\u8bbe\u7f6e\u4ee3\u7801\u7531\u4e8e\u8fc7\u4e8e\u7b80\u5355\u800c\u4e0d\u5b89\u5168\u3002\u6b64\u914d\u4ef6\u4e0d\u7b26\u5408\u57fa\u672c\u5b89\u5168\u8981\u6c42\u3002", "max_peers_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u6ca1\u6709\u7a7a\u95f2\u7684\u914d\u5bf9\u5b58\u50a8\u7a7a\u95f4\u3002", "pairing_failed": "\u5c1d\u8bd5\u4e0e\u6b64\u8bbe\u5907\u914d\u5bf9\u65f6\u53d1\u751f\u672a\u5904\u7406\u7684\u9519\u8bef\u3002\u8fd9\u53ef\u80fd\u662f\u6682\u65f6\u6027\u6545\u969c\uff0c\u4e5f\u53ef\u80fd\u662f\u60a8\u7684\u8bbe\u5907\u76ee\u524d\u4e0d\u88ab\u652f\u6301\u3002", "unable_to_pair": "\u65e0\u6cd5\u914d\u5bf9\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "\u5141\u8bb8\u4f7f\u7528\u4e0d\u5b89\u5168\u7684\u8bbe\u7f6e\u4ee3\u7801\u914d\u5bf9\u3002", "pairing_code": "\u914d\u5bf9\u4ee3\u7801" }, "description": "\u8f93\u5165\u60a8\u7684 HomeKit \u914d\u5bf9\u4ee3\u7801\uff08\u683c\u5f0f\u4e3a XXX-XX-XXX\uff09\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index 4f1c1d12f81..0880d168375 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -62,6 +62,7 @@ HM_DEVICE_TYPES = { "IPWIODevice", "IPSwitchBattery", "IPMultiIOPCB", + "IPGarageSwitch", ], DISCOVER_LIGHTS: [ "Dimmer", @@ -125,6 +126,7 @@ HM_DEVICE_TYPES = { "TempModuleSTE2", "IPMultiIOPCB", "ValveBoxW", + "CO2SensorIP", ], DISCOVER_CLIMATE: [ "Thermostat", diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index 8b1ee62a09e..f500ef54b56 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -2,7 +2,7 @@ "domain": "homematic", "name": "Homematic", "documentation": "https://www.home-assistant.io/integrations/homematic", - "requirements": ["pyhomematic==0.1.73"], + "requirements": ["pyhomematic==0.1.74"], "codeowners": ["@pvizeli", "@danielperna84"], "iot_class": "local_push" } diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index ad62001d5f9..18690ac3553 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -3,7 +3,9 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, DEGREE, + DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, @@ -72,6 +74,7 @@ HM_UNIT_HA_CAST = { "VALVE_STATE": PERCENTAGE, "CARRIER_SENSE_LEVEL": PERCENTAGE, "DUTY_CYCLE_LEVEL": PERCENTAGE, + "CONCENTRATION": CONCENTRATION_PARTS_PER_MILLION, } HM_DEVICE_CLASS_HA_CAST = { @@ -85,6 +88,7 @@ HM_DEVICE_CLASS_HA_CAST = { "HIGHEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, "POWER": DEVICE_CLASS_POWER, "CURRENT": DEVICE_CLASS_POWER, + "CONCENTRATION": DEVICE_CLASS_CO2, } HM_ICON_HA_CAST = {"WIND_SPEED": "mdi:weather-windy", "BRIGHTNESS": "mdi:invert-colors"} @@ -107,7 +111,7 @@ class HMSensor(HMDevice, SensorEntity): """Representation of a HomeMatic sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" # Does a cast exist for this class? name = self._hmdevice.__class__.__name__ @@ -118,7 +122,7 @@ class HMSensor(HMDevice, SensorEntity): return self._hm_get_state() @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return HM_UNIT_HA_CAST.get(self._state) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 00604bbc8a6..14c80f56b1a 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -52,7 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entry.data[HMIPC_HAPID] for entry in hass.config_entries.async_entries(DOMAIN) }: - hass.async_add_job( + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 87a8056b4b6..212737b7018 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from . import DOMAIN as HMIPC_DOMAIN -from .hap import HomematicipHAP +from .hap import AsyncHome, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,7 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" - self._home = hap.home + self._home: AsyncHome = hap.home _LOGGER.info("Setting up %s", self.name) @property diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 673dd6e9ea3..80dfa8316d0 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -85,7 +85,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [HomematicipCloudConnectionSensor(hap)] + entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)] for device in hap.home.devices: if isinstance(device, AsyncAccelerationSensor): entities.append(HomematicipAccelerationSensor(hap, device)) @@ -254,7 +254,7 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt return DEVICE_CLASS_OPENING @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if the contact interface is on/open.""" if self._device.functionalChannels[self._channel].windowState is None: return None diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 7ba90e0a9e4..1b6c2491e2e 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -262,7 +262,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): return self._home.get_functionalHome(IndoorClimateHome) @property - def _device_profiles(self) -> list[str]: + def _device_profiles(self) -> list[Any]: """Return the relevant profiles.""" return [ profile @@ -301,10 +301,10 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ) @property - def _relevant_profile_group(self) -> list[str]: + def _relevant_profile_group(self) -> dict[str, int]: """Return the relevant profile groups.""" if self._disabled_by_cooling_mode: - return [] + return {} return HEATING_PROFILES if self._heat_mode_enabled else COOLING_PROFILES diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 2baa99068ce..6cf6335b874 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -23,9 +23,10 @@ class HomematicipCloudFlowHandler(config_entries.ConfigFlow): VERSION = 1 + auth: HomematicipAuth + def __init__(self) -> None: """Initialize HomematicIP Cloud config flow.""" - self.auth = None async def async_step_user(self, user_input=None) -> FlowResult: """Handle a flow initialized by the user.""" diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 843f44510c1..0d0278ac455 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -37,7 +37,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP cover from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBlindModule): entities.append(HomematicipBlindModule(hap, device)) @@ -72,14 +72,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): return DEVICE_CLASS_BLIND @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.primaryShadingLevel is not None: return int((1 - self._device.primaryShadingLevel) * 100) return None @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.secondaryShadingLevel is not None: return int((1 - self._device.secondaryShadingLevel) * 100) @@ -165,7 +165,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): return DEVICE_CLASS_SHUTTER @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.functionalChannels[self._channel].shutterLevel is not None: return int( @@ -227,7 +227,7 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): ) @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.functionalChannels[self._channel].slatsLevel is not None: return int( @@ -267,7 +267,7 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP Garage Door Module.""" @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" door_state_to_position = { DoorState.CLOSED: 0, @@ -314,14 +314,14 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): return DEVICE_CLASS_SHUTTER @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return current position of cover.""" if self._device.shutterLevel is not None: return int((1 - self._device.shutterLevel) * 100) return None @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" if self._device.slatsLevel is not None: return int((1 - self._device.slatsLevel) * 100) diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index b9dd46d49d7..f1edff1854b 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -13,7 +13,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN as HMIPC_DOMAIN -from .hap import HomematicipHAP +from .hap import AsyncHome, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -82,7 +82,7 @@ class HomematicipGenericEntity(Entity): ) -> None: """Initialize the generic entity.""" self._hap = hap - self._home = hap.home + self._home: AsyncHome = hap.home self._device = device self._post = post self._channel = channel @@ -92,7 +92,7 @@ class HomematicipGenericEntity(Entity): _LOGGER.info("Setting up %s (%s)", self.name, self._device.modelType) @property - def device_info(self) -> DeviceInfo: + def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" # Only physical devices should be HA devices. if isinstance(self._device, AsyncDevice): diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index ad641c0f46d..5cccc9a9999 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -1,6 +1,9 @@ """Access point for the HomematicIP Cloud component.""" +from __future__ import annotations + import asyncio import logging +from typing import Any, Callable from homematicip.aio.auth import AsyncAuth from homematicip.aio.home import AsyncHome @@ -21,11 +24,12 @@ _LOGGER = logging.getLogger(__name__) class HomematicipAuth: """Manages HomematicIP client registration.""" + auth: AsyncAuth + def __init__(self, hass, config) -> None: """Initialize HomematicIP Cloud client registration.""" self.hass = hass self.config = config - self.auth = None async def async_setup(self) -> bool: """Connect to HomematicIP for registration.""" @@ -69,18 +73,19 @@ class HomematicipAuth: class HomematicipHAP: """Manages HomematicIP HTTP and WebSocket connection.""" + home: AsyncHome + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize HomematicIP Cloud connection.""" self.hass = hass self.config_entry = config_entry - self.home = None self._ws_close_requested = False - self._retry_task = None + self._retry_task: asyncio.Task | None = None self._tries = 0 self._accesspoint_connected = True - self.hmip_device_by_entity_id = {} - self.reset_connection_listener = None + self.hmip_device_by_entity_id: dict[str, Any] = {} + self.reset_connection_listener: Callable | None = None async def async_setup(self, tries: int = 0) -> bool: """Initialize connection.""" @@ -228,7 +233,11 @@ class HomematicipHAP: ) async def get_hap( - self, hass: HomeAssistant, hapid: str, authtoken: str, name: str + self, + hass: HomeAssistant, + hapid: str | None, + authtoken: str | None, + name: str | None, ) -> AsyncHome: """Create a HomematicIP access point object.""" home = AsyncHome(hass.loop, async_get_clientsession(hass)) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index a2f2a6aea53..52ca9de2fe4 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -40,7 +40,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): entities.append(HomematicipLightMeasuring(hap, device)) @@ -174,14 +174,14 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): hap, device, post="Bottom", channel=channel, is_multi_channel=True ) - self._color_switcher = { - RGBColorState.WHITE: [0.0, 0.0], - RGBColorState.RED: [0.0, 100.0], - RGBColorState.YELLOW: [60.0, 100.0], - RGBColorState.GREEN: [120.0, 100.0], - RGBColorState.TURQUOISE: [180.0, 100.0], - RGBColorState.BLUE: [240.0, 100.0], - RGBColorState.PURPLE: [300.0, 100.0], + self._color_switcher: dict[str, tuple[float, float]] = { + RGBColorState.WHITE: (0.0, 0.0), + RGBColorState.RED: (0.0, 100.0), + RGBColorState.YELLOW: (60.0, 100.0), + RGBColorState.GREEN: (120.0, 100.0), + RGBColorState.TURQUOISE: (180.0, 100.0), + RGBColorState.BLUE: (240.0, 100.0), + RGBColorState.PURPLE: (300.0, 100.0), } @property @@ -202,10 +202,10 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): return int((self._func_channel.dimLevel or 0.0) * 255) @property - def hs_color(self) -> tuple: + def hs_color(self) -> tuple[float, float]: """Return the hue and saturation color value [float, float].""" simple_rgb_color = self._func_channel.simpleRGBColorState - return self._color_switcher.get(simple_rgb_color, [0.0, 0.0]) + return self._color_switcher.get(simple_rgb_color, (0.0, 0.0)) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 475df8ec2af..ae2bb9f0c6d 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -66,7 +66,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncHomeControlAccessPoint): entities.append(HomematicipAccesspointDutyCycle(hap, device)) @@ -137,12 +137,12 @@ class HomematicipAccesspointDutyCycle(HomematicipGenericEntity, SensorEntity): return "mdi:access-point-network" @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the access point.""" return self._device.dutyCycleLevel @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -155,7 +155,7 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Heating") @property - def icon(self) -> str: + def icon(self) -> str | None: """Return the icon.""" if super().icon: return super().icon @@ -164,14 +164,14 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity): return "mdi:radiator" @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the radiator valve.""" if self._device.valveState != ValveState.ADAPTION_DONE: return self._device.valveState return round(self._device.valvePosition * 100) @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -189,12 +189,12 @@ class HomematicipHumiditySensor(HomematicipGenericEntity, SensorEntity): return DEVICE_CLASS_HUMIDITY @property - def state(self) -> int: + def native_value(self) -> int: """Return the state.""" return self._device.humidity @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return PERCENTAGE @@ -212,7 +212,7 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): return DEVICE_CLASS_TEMPERATURE @property - def state(self) -> float: + def native_value(self) -> float: """Return the state.""" if hasattr(self._device, "valveActualTemperature"): return self._device.valveActualTemperature @@ -220,7 +220,7 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): return self._device.actualTemperature @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return TEMP_CELSIUS @@ -249,7 +249,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): return DEVICE_CLASS_ILLUMINANCE @property - def state(self) -> float: + def native_value(self) -> float: """Return the state.""" if hasattr(self._device, "averageIllumination"): return self._device.averageIllumination @@ -257,7 +257,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): return self._device.illumination @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return LIGHT_LUX @@ -287,12 +287,12 @@ class HomematicipPowerSensor(HomematicipGenericEntity, SensorEntity): return DEVICE_CLASS_POWER @property - def state(self) -> float: + def native_value(self) -> float: """Return the power consumption value.""" return self._device.currentPowerConsumption @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return POWER_WATT @@ -305,12 +305,12 @@ class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Windspeed") @property - def state(self) -> float: + def native_value(self) -> float: """Return the wind speed value.""" return self._device.windSpeed @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return SPEED_KILOMETERS_PER_HOUR @@ -338,12 +338,12 @@ class HomematicipTodayRainSensor(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Today Rain") @property - def state(self) -> float: + def native_value(self) -> float: """Return the today's rain value.""" return round(self._device.todayRainCounter, 2) @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return LENGTH_MILLIMETERS @@ -352,7 +352,7 @@ class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEnt """Representation of the HomematicIP passage detector delta counter.""" @property - def state(self) -> int: + def native_value(self) -> int: """Return the passage detector delta counter value.""" return self._device.leftRightCounterDelta diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index bafe7599f06..45795f8858e 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -297,7 +297,9 @@ async def _set_active_climate_profile( async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None: """Service to dump the configuration of a Homematic IP Access Point.""" - config_path = service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir + config_path: str = ( + service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir or "." + ) config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] anonymize = service.data[ATTR_ANONYMIZE] diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 3ea52c9fb89..90188fd0322 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -34,7 +34,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP switch from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): # BrandSwitchMeasuring inherits PlugableSwitchMeasuring diff --git a/homeassistant/components/homematicip_cloud/translations/lt.json b/homeassistant/components/homematicip_cloud/translations/lt.json new file mode 100644 index 00000000000..a270a8acbc2 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "init": { + "data": { + "pin": "PIN kodas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index dcd8ff4dff7..d371e305d87 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -1,4 +1,6 @@ """Support for HomematicIP Cloud weather devices.""" +from __future__ import annotations + from homematicip.aio.device import ( AsyncWeatherSensor, AsyncWeatherSensorPlus, @@ -50,7 +52,7 @@ async def async_setup_entry( ) -> None: """Set up the HomematicIP weather sensor from a config entry.""" hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] - entities = [] + entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncWeatherSensorPro): entities.append(HomematicipWeatherSensorPro(hap, device)) @@ -170,6 +172,6 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): return "Powered by Homematic IP" @property - def condition(self) -> str: + def condition(self) -> str | None: """Return the current condition.""" return HOME_WEATHER_CONDITION.get(self._device.weather.weatherCondition) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 48f2802e89f..29f0dbb8392 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass, config): _LOGGER.debug("No devices found") return False - data = HoneywellService(hass, client, username, password, devices[0]) + data = HoneywellData(hass, client, username, password, devices) await data.update() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = data @@ -65,16 +65,16 @@ def get_somecomfort_client(username, password): ) from ex -class HoneywellService: +class HoneywellData: """Get the latest data and update.""" - def __init__(self, hass, client, username, password, device): + def __init__(self, hass, client, username, password, devices): """Initialize the data object.""" self._hass = hass self._client = client self._username = username self._password = password - self.device = device + self.devices = devices async def _retry(self) -> bool: """Recreate a new somecomfort client. @@ -93,23 +93,27 @@ class HoneywellService: device for location in self._client.locations_by_id.values() for device in location.devices_by_id.values() - if device.name == self.device.name ] - if len(devices) != 1: - _LOGGER.error("Failed to find device %s", self.device.name) + if len(devices) == 0: + _LOGGER.error("Failed to find any devices") return False - self.device = devices[0] + self.devices = devices return True + def _refresh_devices(self): + """Refresh each enabled device.""" + for device in self.devices: + device.refresh() + @Throttle(MIN_TIME_BETWEEN_UPDATES) async def update(self) -> None: """Update the state.""" retries = 3 while retries > 0: try: - await self._hass.async_add_executor_job(self.device.refresh) + await self._hass.async_add_executor_job(self._refresh_devices) break except ( somecomfort.client.APIRateLimited, @@ -126,7 +130,3 @@ class HoneywellService: raise exp _LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp) - - _LOGGER.debug( - "latestData = %s ", self.device._data # pylint: disable=protected-access - ) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 36fe16aeaa2..230aa8ec424 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -41,7 +41,6 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from .const import ( _LOGGER, @@ -116,7 +115,12 @@ async def async_setup_entry(hass, config, async_add_entities, discovery_info=Non data = hass.data[DOMAIN][config.entry_id] - async_add_entities([HoneywellUSThermostat(data, cool_away_temp, heat_away_temp)]) + async_add_entities( + [ + HoneywellUSThermostat(data, device, cool_away_temp, heat_away_temp) + for device in data.devices + ] + ) async def async_setup_platform(hass, config, add_entities, discovery_info=None): @@ -142,25 +146,24 @@ async def async_setup_platform(hass, config, add_entities, discovery_info=None): class HoneywellUSThermostat(ClimateEntity): """Representation of a Honeywell US Thermostat.""" - def __init__(self, data, cool_away_temp, heat_away_temp): + def __init__(self, data, device, cool_away_temp, heat_away_temp): """Initialize the thermostat.""" self._data = data + self._device = device self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False - self._attr_unique_id = dr.format_mac(data.device.mac_address) - self._attr_name = data.device.name + self._attr_unique_id = device.deviceid + self._attr_name = device.name self._attr_temperature_unit = ( - TEMP_CELSIUS if data.device.temperature_unit == "C" else TEMP_FAHRENHEIT + TEMP_CELSIUS if device.temperature_unit == "C" else TEMP_FAHRENHEIT ) self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY] - self._attr_is_aux_heat = data.device.system_mode == "emheat" + self._attr_is_aux_heat = device.system_mode == "emheat" # not all honeywell HVACs support all modes - mappings = [ - v for k, v in HVAC_MODE_TO_HW_MODE.items() if data.device.raw_ui_data[k] - ] + mappings = [v for k, v in HVAC_MODE_TO_HW_MODE.items() if device.raw_ui_data[k]] self._hvac_mode_map = {k: v for d in mappings for k, v in d.items()} self._attr_hvac_modes = list(self._hvac_mode_map) @@ -170,28 +173,23 @@ class HoneywellUSThermostat(ClimateEntity): | SUPPORT_TARGET_TEMPERATURE_RANGE ) - if data.device._data["canControlHumidification"]: + if device._data["canControlHumidification"]: self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY - if data.device.raw_ui_data["SwitchEmergencyHeatAllowed"]: + if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: self._attr_supported_features |= SUPPORT_AUX_HEAT - if not data.device._data["hasFan"]: + if not device._data["hasFan"]: return # not all honeywell fans support all modes - mappings = [v for k, v in FAN_MODE_TO_HW.items() if data.device.raw_fan_data[k]] + mappings = [v for k, v in FAN_MODE_TO_HW.items() if device.raw_fan_data[k]] self._fan_mode_map = {k: v for d in mappings for k, v in d.items()} self._attr_fan_modes = list(self._fan_mode_map) self._attr_supported_features |= SUPPORT_FAN_MODE - @property - def _device(self): - """Shortcut to access the device.""" - return self._data.device - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" diff --git a/homeassistant/components/honeywell/translations/es.json b/homeassistant/components/honeywell/translations/es.json new file mode 100644 index 00000000000..41534be9d8d --- /dev/null +++ b/homeassistant/components/honeywell/translations/es.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "Por favor, introduzca las credenciales utilizadas para iniciar sesi\u00f3n en mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/hu.json b/homeassistant/components/honeywell/translations/hu.json new file mode 100644 index 00000000000..5583dc22f2e --- /dev/null +++ b/homeassistant/components/honeywell/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "K\u00e9rj\u00fck, adja meg a mytotalconnectcomfort.com webhelyre val\u00f3 bejelentkez\u00e9shez haszn\u00e1lt hiteles\u00edt\u0151 adatokat.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/no.json b/homeassistant/components/honeywell/translations/no.json new file mode 100644 index 00000000000..97d31d34961 --- /dev/null +++ b/homeassistant/components/honeywell/translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Vennligst skriv inn legitimasjonen som brukes for \u00e5 logge deg p\u00e5 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/zh-Hans.json b/homeassistant/components/honeywell/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/honeywell/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index 297bfa5264f..5a44a2937e8 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -133,12 +133,12 @@ class HpIloSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the sensor.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 9abf0914b06..129c43600c4 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -158,7 +158,7 @@ def request_handler_factory( else: assert ( False - ), f"Result should be None, string, bytes or Response. Got: {result}" + ), f"Result should be None, string, bytes or StreamResponse. Got: {result}" return web.Response(body=bresult, status=status_code) diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py index ccbe6a31de2..d43a0733daf 100644 --- a/homeassistant/components/htu21d/sensor.py +++ b/homeassistant/components/htu21d/sensor.py @@ -1,4 +1,6 @@ """Support for HTU21D temperature and humidity sensor.""" +from __future__ import annotations + from datetime import timedelta from functools import partial import logging @@ -7,17 +9,20 @@ from i2csense.htu21d import HTU21D # pylint: disable=import-error import smbus 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_NAME, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -31,6 +36,19 @@ DEFAULT_NAME = "HTU21D Sensor" SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -38,17 +56,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -DEVICE_CLASS_MAP = { - SENSOR_TEMPERATURE: DEVICE_CLASS_TEMPERATURE, - SENSOR_HUMIDITY: DEVICE_CLASS_HUMIDITY, -} - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the HTU21D sensor.""" name = config.get(CONF_NAME) bus_number = config.get(CONF_I2C_BUS) - temp_unit = hass.config.units.temperature_unit bus = smbus.SMBus(config.get(CONF_I2C_BUS)) sensor = await hass.async_add_executor_job(partial(HTU21D, bus, logger=_LOGGER)) @@ -58,12 +70,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensor_handler = await hass.async_add_executor_job(HTU21DHandler, sensor) - dev = [ - HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit), - HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, PERCENTAGE), + entities = [ + HTU21DSensor(sensor_handler, name, description) for description in SENSOR_TYPES ] - async_add_entities(dev) + async_add_entities(entities) class HTU21DHandler: @@ -83,40 +94,21 @@ class HTU21DHandler: class HTU21DSensor(SensorEntity): """Implementation of the HTU21D sensor.""" - def __init__(self, htu21d_client, name, variable, unit): + def __init__(self, htu21d_client, name, description: SensorEntityDescription): """Initialize the sensor.""" - self._name = f"{name}_{variable}" - self._variable = variable - self._unit_of_measurement = unit + self.entity_description = description self._client = htu21d_client - self._state = None - self._attr_device_class = DEVICE_CLASS_MAP[variable] - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def state(self) -> int: - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement + self._attr_name = f"{name}_{description.key}" async def async_update(self): """Get the latest data from the HTU21D sensor and update the state.""" await self.hass.async_add_executor_job(self._client.update) if self._client.sensor.sample_ok: - if self._variable == SENSOR_TEMPERATURE: + if self.entity_description.key == SENSOR_TEMPERATURE: value = round(self._client.sensor.temperature, 1) - if self.unit_of_measurement == TEMP_FAHRENHEIT: - value = celsius_to_fahrenheit(value) else: value = round(self._client.sensor.humidity, 1) - self._state = value + self._attr_native_value = value else: _LOGGER.warning("Bad sample") diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 0c545486c82..ec9281659f5 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -185,11 +185,6 @@ class Router: _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key]) try: self.data[key] = func() - except ResponseErrorNotSupportedException: - _LOGGER.info( - "%s not supported by device, excluding from future updates", key - ) - self.subscriptions.pop(key) except ResponseErrorLoginRequiredException: if isinstance(self.connection, AuthorizedConnection): _LOGGER.debug("Trying to authorize again") @@ -206,7 +201,13 @@ class Router: ) self.subscriptions.pop(key) except ResponseErrorException as exc: - if exc.code != -1: + if not isinstance( + exc, ResponseErrorNotSupportedException + ) and exc.code not in ( + # additional codes treated as unusupported + -1, + 100006, + ): raise _LOGGER.info( "%s apparently not supported by device, excluding from future updates", @@ -298,7 +299,7 @@ class Router: class HuaweiLteData: """Shared state.""" - hass_config: dict = attr.ib() + hass_config: ConfigType = attr.ib() # Our YAML config, keyed by router URL config: dict[str, dict[str, Any]] = attr.ib() routers: dict[str, Router] = attr.ib(init=False, factory=dict) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 4340d5912c9..47987e5607e 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -426,7 +426,7 @@ class HuaweiLteSensor(HuaweiLteBaseEntity, SensorEntity): return f"{self.key}.{self.item}" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return sensor state.""" return self._state @@ -436,7 +436,7 @@ class HuaweiLteSensor(HuaweiLteBaseEntity, SensorEntity): return self.meta.device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return sensor's unit of measurement.""" return self.meta.unit or self._unit diff --git a/homeassistant/components/huawei_lte/translations/hu.json b/homeassistant/components/huawei_lte/translations/hu.json index eff9c8a813b..22bd37c37ba 100644 --- a/homeassistant/components/huawei_lte/translations/hu.json +++ b/homeassistant/components/huawei_lte/translations/hu.json @@ -11,6 +11,8 @@ "incorrect_username": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "invalid_url": "\u00c9rv\u00e9nytelen URL", + "login_attempts_exceeded": "T\u00fall\u00e9pte a maxim\u00e1lis bejelentkez\u00e9si k\u00eds\u00e9rleteket. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra k\u00e9s\u0151bb", + "response_error": "Ismeretlen hiba az eszk\u00f6zr\u0151l", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name}", @@ -21,6 +23,7 @@ "url": "URL", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Adja meg az eszk\u00f6z hozz\u00e1f\u00e9r\u00e9si adatait.", "title": "Huawei LTE konfigur\u00e1l\u00e1sa" } } diff --git a/homeassistant/components/huawei_lte/translations/nl.json b/homeassistant/components/huawei_lte/translations/nl.json index 715efbfd506..e65c261d62b 100644 --- a/homeassistant/components/huawei_lte/translations/nl.json +++ b/homeassistant/components/huawei_lte/translations/nl.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Gebruikersnaam" }, - "description": "Voer de toegangsgegevens van het apparaat in. Opgeven van gebruikersnaam en wachtwoord is optioneel, maar biedt ondersteuning voor meer integratiefuncties. Aan de andere kant kan het gebruik van een geautoriseerde verbinding problemen veroorzaken bij het openen van het webinterface van het apparaat buiten de Home Assitant, terwijl de integratie actief is en andersom.", + "description": "Voer de toegangsgegevens van het apparaat in.", "title": "Configureer Huawei LTE" } } diff --git a/homeassistant/components/huawei_lte/translations/no.json b/homeassistant/components/huawei_lte/translations/no.json index a328858c57f..3c8b26ab0cd 100644 --- a/homeassistant/components/huawei_lte/translations/no.json +++ b/homeassistant/components/huawei_lte/translations/no.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Brukernavn" }, - "description": "Fyll inn detaljer for enhetstilgang. Spesifisering av brukernavn og passord er valgfritt, men gir st\u00f8tte for flere integrasjonsfunksjoner. P\u00e5 en annen side kan bruk av en autorisert tilkobling f\u00f8re til problemer med tilgang til enhetens webgrensesnitt utenfor Home Assistant mens integrasjonen er aktiv, og omvendt.", + "description": "Angi enhetsadgangsdetaljer.", "title": "Konfigurer Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Navn p\u00e5 varslingstjeneste (endring krever omstart)", "recipient": "Mottakere av SMS-varsling", "track_new_devices": "Spor nye enheter", - "track_wired_clients": "Spor kablede nettverksklienter" + "track_wired_clients": "Spor kablede nettverksklienter", + "unauthenticated_mode": "Uautentisert modus (endring krever omlasting)" } } } diff --git a/homeassistant/components/huawei_lte/translations/zh-Hans.json b/homeassistant/components/huawei_lte/translations/zh-Hans.json index 987c53e4d5c..4fb447403d6 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hans.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "error": { - "incorrect_username": "\u7528\u6237\u540d\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" } } } \ No newline at end of file diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/hue_event.py index 6bd68b106bb..069bb1e58b5 100644 --- a/homeassistant/components/hue/hue_event.py +++ b/homeassistant/components/hue/hue_event.py @@ -60,10 +60,17 @@ class HueEvent(GenericHueDevice): self.sensor.last_event is not None and self.sensor.last_event["type"] != EVENT_BUTTON ) - or - # Filter out old states. Can happen when events fire while refreshing - dt_util.parse_datetime(self.sensor.state["lastupdated"]) - <= dt_util.parse_datetime(self._last_state["lastupdated"]) + ): + return + + # Filter out old states. Can happen when events fire while refreshing + now_updated = dt_util.parse_datetime(self.sensor.state["lastupdated"]) + last_updated = dt_util.parse_datetime(self._last_state["lastupdated"]) + + if ( + now_updated is not None + and last_updated is not None + and now_updated <= last_updated ): return diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 345156de7d7..ea89d91113b 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -282,12 +282,14 @@ class HueLight(CoordinatorEntity, LightEntity): self.is_osram = False self.is_philips = False self.is_innr = False + self.is_livarno = False self.gamut_typ = GAMUT_TYPE_UNAVAILABLE self.gamut = None else: self.is_osram = light.manufacturername == "OSRAM" self.is_philips = light.manufacturername == "Philips" self.is_innr = light.manufacturername == "innr" + self.is_livarno = light.manufacturername.startswith("_TZ3000_") self.gamut_typ = self.light.colorgamuttype self.gamut = self.light.colorgamut _LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut)) @@ -383,6 +385,8 @@ class HueLight(CoordinatorEntity, LightEntity): """Return the warmest color_temp that this light supports.""" if self.is_group: return super().max_mireds + if self.is_livarno: + return 500 max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") @@ -493,7 +497,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr: + elif not self.is_innr and not self.is_livarno: command["alert"] = "none" if ATTR_EFFECT in kwargs: @@ -532,7 +536,7 @@ class HueLight(CoordinatorEntity, LightEntity): elif flash == FLASH_SHORT: command["alert"] = "select" del command["on"] - elif not self.is_innr: + elif not self.is_innr and not self.is_livarno: command["alert"] = "none" if self.is_group: diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index a512012bc68..80658fff21e 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -42,10 +42,10 @@ class HueLightLevel(GenericHueGaugeSensorEntity): """The light level sensor entity for a Hue motion sensor device.""" _attr_device_class = DEVICE_CLASS_ILLUMINANCE - _attr_unit_of_measurement = LIGHT_LUX + _attr_native_unit_of_measurement = LIGHT_LUX @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.sensor.lightlevel is None: return None @@ -78,10 +78,10 @@ class HueTemperature(GenericHueGaugeSensorEntity): _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.sensor.temperature is None: return None @@ -94,7 +94,7 @@ class HueBattery(GenericHueSensor, SensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property def unique_id(self): @@ -102,7 +102,7 @@ class HueBattery(GenericHueSensor, SensorEntity): return f"{self.sensor.uniqueid}-battery" @property - def state(self): + def native_value(self): """Return the state of the battery.""" return self.sensor.battery diff --git a/homeassistant/components/hue/translations/da.json b/homeassistant/components/hue/translations/da.json index 031076172ac..f081a912dd7 100644 --- a/homeassistant/components/hue/translations/da.json +++ b/homeassistant/components/hue/translations/da.json @@ -2,22 +2,22 @@ "config": { "abort": { "all_configured": "Alle Philips Hue-broer er allerede konfigureret", - "already_configured": "Bridgen er allerede konfigureret", + "already_configured": "Enhed er allerede konfigureret", "already_in_progress": "Bro-konfiguration er allerede i gang.", - "cannot_connect": "Kunne ikke oprette forbindelse til bridgen", + "cannot_connect": "Kunne ikke oprette forbindelse", "discover_timeout": "Ingen Philips Hue-bro fundet", "no_bridges": "Ingen Philips Hue-broer fundet", "not_hue_bridge": "Ikke en Hue-bro", - "unknown": "Ukendt fejl opstod" + "unknown": "Uventet fejl" }, "error": { - "linking": "Der opstod en ukendt linkfejl.", + "linking": "Der opstod en uventet fejl", "register_failed": "Det lykkedes ikke at registrere, pr\u00f8v igen" }, "step": { "init": { "data": { - "host": "V\u00e6rt" + "host": "Server" }, "title": "V\u00e6lg Hue bridge" }, diff --git a/homeassistant/components/hue/translations/hu.json b/homeassistant/components/hue/translations/hu.json index d0aa043b10b..30084ee9940 100644 --- a/homeassistant/components/hue/translations/hu.json +++ b/homeassistant/components/hue/translations/hu.json @@ -35,12 +35,33 @@ }, "device_automation": { "trigger_subtype": { + "button_1": "Els\u0151 gomb", + "button_2": "M\u00e1sodik gomb", + "button_3": "Harmadik gomb", + "button_4": "Negyedik gomb", + "dim_down": "S\u00f6t\u00e9t\u00edt", + "dim_up": "Vil\u00e1gos\u00edt", + "double_buttons_1_3": "Els\u0151 \u00e9s harmadik gomb", + "double_buttons_2_4": "M\u00e1sodik \u00e9s negyedik gomb", "turn_off": "Kikapcsol\u00e1s", "turn_on": "Bekapcsol\u00e1s" }, "trigger_type": { + "remote_button_long_release": "A \"{subtype}\" gomb hossz\u00fa megnyom\u00e1s ut\u00e1n elengedve", "remote_button_short_press": "\"{subtype}\" gomb lenyomva", - "remote_button_short_release": "\"{subtype}\" gomb elengedve" + "remote_button_short_release": "\"{subtype}\" gomb elengedve", + "remote_double_button_long_press": "Mindk\u00e9t \"{subtype}\" hossz\u00fa megnyom\u00e1st k\u00f6vet\u0151en megjelent", + "remote_double_button_short_press": "Mindk\u00e9t \"{subtype}\" megjelent" + } + }, + "options": { + "step": { + "init": { + "data": { + "allow_hue_groups": "Hue csoportok enged\u00e9lyez\u00e9se", + "allow_unreachable": "Hagyja, hogy az el\u00e9rhetetlen izz\u00f3k helyesen jelents\u00e9k \u00e1llapotukat" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/hue/translations/lt.json b/homeassistant/components/hue/translations/lt.json new file mode 100644 index 00000000000..1e12894085b --- /dev/null +++ b/homeassistant/components/hue/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "init": { + "data": { + "host": "Hostas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 3cda3cdec00..6f18ad27796 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -75,7 +75,7 @@ class HuisbaasjeSensor(CoordinatorEntity, SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.coordinator.data[self._source_type][self._sensor_type] is not None: return round( @@ -85,7 +85,7 @@ class HuisbaasjeSensor(CoordinatorEntity, SensorEntity): return None @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 81df6938236..3ad4b22dcec 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -19,6 +19,8 @@ from homeassistant.helpers.entity import get_capability, get_supported_features from . import DOMAIN, const +# mypy: disallow-any-generics + SET_HUMIDITY_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): "set_humidity", @@ -40,7 +42,9 @@ ONOFF_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DO ACTION_SCHEMA = vol.Any(SET_HUMIDITY_SCHEMA, SET_MODE_SCHEMA, ONOFF_SCHEMA) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Humidifier devices.""" registry = await entity_registry.async_get_registry(hass) actions = await toggle_entity.async_get_actions(hass, device_id, DOMAIN) diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 98bbe192a1f..5c761e798ea 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Climate.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,6 +30,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN +# mypy: disallow-any-generics + TARGET_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { @@ -48,7 +52,9 @@ TOGGLE_TRIGGER_SCHEMA = toggle_entity.TRIGGER_SCHEMA.extend( TRIGGER_SCHEMA = vol.Any(TARGET_TRIGGER_SCHEMA, TOGGLE_TRIGGER_SCHEMA) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Humidifier devices.""" registry = await entity_registry.async_get_registry(hass) triggers = await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) @@ -105,7 +111,9 @@ async def async_attach_trigger( ) -async def async_get_trigger_capabilities(hass: HomeAssistant, config): +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" trigger_type = config[CONF_TYPE] diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 901a048fc7f..22636b7e3c4 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -177,8 +177,6 @@ class PowerViewShade(ShadeEntity, CoverEntity): """Move the shade to a position.""" current_hass_position = hd_position_to_hass(self._current_cover_position) steps_to_move = abs(current_hass_position - target_hass_position) - if not steps_to_move: - return self._async_schedule_update_for_transition(steps_to_move) self._async_update_from_command( await self._shade.move( diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index d66671fe1ea..14501a9c528 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -50,7 +50,7 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): """Representation of an shade battery charge sensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE @@ -70,7 +70,7 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): return f"{self._unique_id}_charge" @property - def state(self): + def native_value(self): """Get the current value in percentage.""" return round( self._shade.raw_data[SHADE_BATTERY_LEVEL] / SHADE_BATTERY_LEVEL_MAX * 100 diff --git a/homeassistant/components/hunterdouglas_powerview/translations/hu.json b/homeassistant/components/hunterdouglas_powerview/translations/hu.json index 3de1b9d0117..1fedd8bc126 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/hu.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/hu.json @@ -9,10 +9,15 @@ }, "flow_title": "{name} ({host})", "step": { + "link": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}({host})-t?", + "title": "Csatlakozzon a PowerView Hubhoz" + }, "user": { "data": { "host": "IP c\u00edm" - } + }, + "title": "Csatlakozzon a PowerView Hubhoz" } } } diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index a3df466da74..8a188f7dde8 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -177,7 +177,7 @@ class HVVDepartureSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/hvv_departures/translations/hu.json b/homeassistant/components/hvv_departures/translations/hu.json index deab9bcb929..dfbdd92f27a 100644 --- a/homeassistant/components/hvv_departures/translations/hu.json +++ b/homeassistant/components/hvv_departures/translations/hu.json @@ -5,15 +5,29 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "no_results": "Nincs eredm\u00e9ny. Pr\u00f3b\u00e1lja ki m\u00e1sik \u00e1llom\u00e1ssal/c\u00edmmel" }, "step": { + "station": { + "data": { + "station": "\u00c1llom\u00e1s/c\u00edm" + }, + "title": "Adja meg az \u00e1llom\u00e1st/c\u00edmet" + }, + "station_select": { + "data": { + "station": "\u00c1llom\u00e1s/c\u00edm" + }, + "title": "\u00c1llom\u00e1s/c\u00edm kiv\u00e1laszt\u00e1sa" + }, "user": { "data": { "host": "Hoszt", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a HVV API-hoz" } } }, @@ -21,8 +35,11 @@ "step": { "init": { "data": { - "offset": "Eltol\u00e1s (perc)" + "filter": "V\u00e1lassza ki a sorokat", + "offset": "Eltol\u00e1s (perc)", + "real_time": "Val\u00f3s idej\u0171 adatok haszn\u00e1lata" }, + "description": "M\u00f3dos\u00edtsa az indul\u00e1si \u00e9rz\u00e9kel\u0151 be\u00e1ll\u00edt\u00e1sait", "title": "Be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 62108afbded..0e9afb6d729 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -40,12 +40,12 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + 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") diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 22134400a45..809449543af 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -210,7 +210,9 @@ class HyperionCamera(Camera): finally: await self._stop_image_streaming_for_client() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return single camera image bytes.""" async with self._image_streaming() as is_streaming: if is_streaming: diff --git a/homeassistant/components/hyperion/translations/en_GB.json b/homeassistant/components/hyperion/translations/en_GB.json new file mode 100644 index 00000000000..1c7cbbfb2f9 --- /dev/null +++ b/homeassistant/components/hyperion/translations/en_GB.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "priority": "Hyperion priority to use for colours and effects" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hyperion/translations/nl.json b/homeassistant/components/hyperion/translations/nl.json index 056971b435f..992d705a533 100644 --- a/homeassistant/components/hyperion/translations/nl.json +++ b/homeassistant/components/hyperion/translations/nl.json @@ -46,7 +46,7 @@ "init": { "data": { "effect_show_list": "Hyperion-effecten om te laten zien", - "priority": "Hyperion prioriteit te gebruiken voor kleuren en effecten" + "priority": "Hyperion prioriteit gebruiken voor kleuren en effecten" } } } diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index b1882619fda..de0e76fc3aa 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -86,7 +86,7 @@ class IamMeter(CoordinatorEntity, SensorEntity): self.dev_name = dev_name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.coordinator.data.data[self.sensor_name] @@ -106,6 +106,6 @@ class IamMeter(CoordinatorEntity, SensorEntity): return "mdi:flash" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.unit diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index ae32db9eb9e..61e4560c3be 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -31,7 +31,7 @@ class HassAqualinkSensor(AqualinkEntity, SensorEntity): return self.dev.label @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the measurement unit for the sensor.""" if self.dev.name.endswith("_temp"): if self.dev.system.temp_unit == "F": @@ -40,7 +40,7 @@ class HassAqualinkSensor(AqualinkEntity, SensorEntity): return None @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" if self.dev.state == "": return None diff --git a/homeassistant/components/iaqualink/translations/hu.json b/homeassistant/components/iaqualink/translations/hu.json index dcb7b906ee3..1ca85c41190 100644 --- a/homeassistant/components/iaqualink/translations/hu.json +++ b/homeassistant/components/iaqualink/translations/hu.json @@ -11,7 +11,9 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "K\u00e9rj\u00fck, adja meg iAqualink-fi\u00f3kja felhaszn\u00e1l\u00f3nev\u00e9t \u00e9s jelszav\u00e1t.", + "title": "Csatlakoz\u00e1s az iAqualinkhez" } } } diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index ec55a1fcedd..5469eadc998 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -54,7 +54,7 @@ class IcloudDeviceBatterySensor(SensorEntity): """Representation of a iCloud device battery sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, account: IcloudAccount, device: IcloudDevice) -> None: """Initialize the battery sensor.""" @@ -73,7 +73,7 @@ class IcloudDeviceBatterySensor(SensorEntity): return f"{self._device.name} battery state" @property - def state(self) -> int: + def native_value(self) -> int: """Battery state percentage.""" return self._device.battery_level diff --git a/homeassistant/components/icloud/translations/hu.json b/homeassistant/components/icloud/translations/hu.json index bb47cdd879b..722b3711e67 100644 --- a/homeassistant/components/icloud/translations/hu.json +++ b/homeassistant/components/icloud/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "no_device": "Egyik k\u00e9sz\u00fcl\u00e9ke sem aktiv\u00e1lta az \"iPhone keres\u00e9se\" funkci\u00f3t", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { @@ -14,6 +15,7 @@ "data": { "password": "Jelsz\u00f3" }, + "description": "A(z) {username} kor\u00e1bban megadott jelszava m\u00e1r nem m\u0171k\u00f6dik. Az integr\u00e1ci\u00f3 haszn\u00e1lat\u00e1hoz friss\u00edtse jelszav\u00e1t.", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, "trusted_device": { @@ -26,7 +28,8 @@ "user": { "data": { "password": "Jelsz\u00f3", - "username": "E-mail" + "username": "E-mail", + "with_family": "Csal\u00e1ddal" }, "description": "Adja meg hiteles\u00edt\u0151 adatait", "title": "iCloud hiteles\u00edt\u0151 adatok" diff --git a/homeassistant/components/ifttt/translations/de.json b/homeassistant/components/ifttt/translations/de.json index 5184e89f29a..216511c62f5 100644 --- a/homeassistant/components/ifttt/translations/de.json +++ b/homeassistant/components/ifttt/translations/de.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "Deine Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Webhook-Nachrichten empfangen zu k\u00f6nnen." }, "create_entry": { - "default": "Um Ereignisse an Home Assistant zu senden, musst du die Aktion \"Eine Webanforderung erstellen\" aus dem [IFTTT Webhook Applet]({applet_url}) ausw\u00e4hlen.\n\nF\u00fclle folgende Informationen aus: \n- URL: `{webhook_url}`\n- Methode: POST\n- Inhaltstyp: application/json\n\nIn der Dokumentation ({docs_url}) findest du Informationen zur Konfiguration der Automation eingehender Daten." + "default": "Um Ereignisse an Home Assistant zu senden, musst du die Aktion \"Eine Webanforderung erstellen\" aus dem [IFTTT Webhook Applet]({applet_url}) ausw\u00e4hlen.\n\nF\u00fclle folgende Informationen aus: \n- URL: `{webhook_url}`\n- Methode: POST\n- Inhaltstyp: application/json\n\nIn [der Dokumentation] ({docs_url}) findest du Informationen zur Konfiguration der Automation eingehender Daten." }, "step": { "user": { diff --git a/homeassistant/components/ifttt/translations/zh-Hans.json b/homeassistant/components/ifttt/translations/zh-Hans.json index c9e8bfd6044..78cbc37a7d9 100644 --- a/homeassistant/components/ifttt/translations/zh-Hans.json +++ b/homeassistant/components/ifttt/translations/zh-Hans.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "single_instance_allowed": "\u5b9e\u4f8b\u5df2\u914d\u7f6e\uff0c\u4e14\u53ea\u80fd\u5b58\u5728\u5355\u4e2a\u914d\u7f6e\u3002", + "webhook_not_internet_accessible": "Home Assistant \u9700\u8981\u7f51\u7edc\u8fde\u63a5\u4ee5\u83b7\u53d6\u76f8\u5173\u63a8\u9001\u4fe1\u606f\u3002" + }, "create_entry": { "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u4f7f\u7528 [IFTTT Webhook applet]({applet_url}) \u4e2d\u7684 \"Make a web request\" \u52a8\u4f5c\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002" }, diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index d1aec781df7..17c17980c95 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -48,12 +48,12 @@ class IHCSensor(IHCDevice, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index e27abf70127..51263e38ab7 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .const import DOMAIN @@ -37,7 +38,7 @@ UPDATE_FIELDS = { } -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Image integration.""" image_dir = pathlib.Path(hass.config.path(DOMAIN)) hass.data[DOMAIN] = storage_collection = ImageStorageCollection(hass, image_dir) diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index ed4be6047e0..620bd351806 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -4,3 +4,5 @@ scan: name: Scan description: Process an image immediately target: + entity: + domain: image_processing diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 4158d1be801..c3d6b2198ce 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -95,7 +95,7 @@ class ImapSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the number of emails found.""" return self._email_count diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index cdd47d68d76..87c18a56bbe 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -165,7 +165,7 @@ class EmailContentSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the current email state.""" return self._message diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index a9e1faaba10..9fb99321ff2 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -59,7 +59,7 @@ class IncomfortSensor(IncomfortChild, SensorEntity): self._unit_of_measurement = None @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._heater.status[self._state_attr] @@ -69,7 +69,7 @@ class IncomfortSensor(IncomfortChild, SensorEntity): return self._device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor.""" return self._unit_of_measurement diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index c2cb5070a4c..bdbfafaf790 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -222,12 +222,12 @@ class InfluxSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/insteon/translations/fa.json b/homeassistant/components/insteon/translations/fa.json new file mode 100644 index 00000000000..2456fbcba00 --- /dev/null +++ b/homeassistant/components/insteon/translations/fa.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0628\u0647 \u062f\u0631\u0633\u062a\u06cc \u062a\u0646\u0638\u06cc\u0645 \u0634\u062f\u0647 \u0627\u0633\u062a. \u062a\u0646\u0647\u0627 \u06cc\u06a9 \u062a\u0646\u0638\u06cc\u0645 \u0627\u0645\u06a9\u0627\u0646 \u067e\u0630\u06cc\u0631 \u0627\u0633\u062a." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/insteon/translations/hu.json b/homeassistant/components/insteon/translations/hu.json index 462fae3e1cb..8444aa97655 100644 --- a/homeassistant/components/insteon/translations/hu.json +++ b/homeassistant/components/insteon/translations/hu.json @@ -31,6 +31,7 @@ "data": { "device": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" }, + "description": "Konfigur\u00e1lja az Insteon PowerLink modemet (PLM).", "title": "Insteon PLM" }, "user": { @@ -44,16 +45,28 @@ }, "options": { "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "input_error": "\u00c9rv\u00e9nytelen bejegyz\u00e9sek, ellen\u0151rizze \u00e9rt\u00e9keket.", + "select_single": "V\u00e1lassz 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)" + }, + "description": "Eszk\u00f6z-fel\u00fclb\u00edr\u00e1l\u00e1s hozz\u00e1ad\u00e1sa.", "title": "Insteon" }, "add_x10": { "data": { + "housecode": "H\u00e1zk\u00f3d (a - p)", + "platform": "Platform", + "steps": "F\u00e9nyer\u0151-szab\u00e1lyoz\u00e1si l\u00e9p\u00e9sek (csak k\u00f6nny\u0171 eszk\u00f6z\u00f6k eset\u00e9n, alap\u00e9rtelmezett 22)", "unitcode": "Egys\u00e9gk\u00f3d (1 - 16)" }, + "description": "M\u00f3dos\u00edtsa az Insteon Hub jelszav\u00e1t.", "title": "Insteon" }, "change_hub_config": { @@ -63,15 +76,25 @@ "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "M\u00f3dos\u00edtsa az Insteon Hub csatlakoz\u00e1si adatait. A m\u00f3dos\u00edt\u00e1s elv\u00e9gz\u00e9se ut\u00e1n \u00fajra kell ind\u00edtania a Home Assistant alkalmaz\u00e1st. Ez nem v\u00e1ltoztatja meg a Hub konfigur\u00e1ci\u00f3j\u00e1t. A Hub konfigur\u00e1ci\u00f3j\u00e1nak m\u00f3dos\u00edt\u00e1s\u00e1hoz haszn\u00e1lja a Hub alkalmaz\u00e1st.", "title": "Insteon" }, "init": { "data": { - "add_x10": "Adjon hozz\u00e1 egy X10 eszk\u00f6zt." + "add_override": "Eszk\u00f6z-fel\u00fclb\u00edr\u00e1l\u00e1s hozz\u00e1ad\u00e1sa.", + "add_x10": "Adjon hozz\u00e1 egy X10 eszk\u00f6zt.", + "change_hub_config": "M\u00f3dos\u00edtsa a Hub konfigur\u00e1ci\u00f3j\u00e1t.", + "remove_override": "Egy eszk\u00f6z fel\u00fclb\u00edr\u00e1lat\u00e1nak elt\u00e1vol\u00edt\u00e1sa.", + "remove_x10": "T\u00e1vol\u00edtson el egy X10 eszk\u00f6zt." }, + "description": "V\u00e1lasszon egy be\u00e1ll\u00edt\u00e1st.", "title": "Insteon" }, "remove_override": { + "data": { + "address": "V\u00e1lassza ki az elt\u00e1vol\u00edtani k\u00edv\u00e1nt eszk\u00f6z c\u00edm\u00e9t" + }, + "description": "T\u00e1vol\u00edtsa el az eszk\u00f6z fel\u00fclb\u00edr\u00e1l\u00e1s\u00e1t", "title": "Insteon" }, "remove_x10": { diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index b9fd1da4e42..b8e72c3be5c 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -5,11 +5,10 @@ import logging import voluptuous as vol from homeassistant.components.sensor import ( - ATTR_LAST_RESET, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, PLATFORM_SCHEMA, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -28,7 +27,6 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt as dt_util # mypy: allow-untyped-defs, no-check-untyped-defs @@ -124,25 +122,18 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] - self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() state = await self.async_get_last_state() - self._attr_last_reset = dt_util.utcnow() if state: try: self._state = Decimal(state.state) except (DecimalException, ValueError) as err: _LOGGER.warning("Could not restore last state: %s", err) else: - last_reset = dt_util.parse_datetime( - state.attributes.get(ATTR_LAST_RESET, "") - ) - self._attr_last_reset = ( - last_reset if last_reset else dt_util.utc_from_timestamp(0) - ) self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) self._unit_of_measurement = state.attributes.get( @@ -154,12 +145,6 @@ class IntegrationSensor(RestoreEntity, SensorEntity): """Handle the sensor state changes.""" old_state = event.data.get("old_state") new_state = event.data.get("new_state") - if ( - old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - ): - return if self._unit_of_measurement is None: unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -171,6 +156,14 @@ class IntegrationSensor(RestoreEntity, SensorEntity): and new_state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER ): self._attr_device_class = DEVICE_CLASS_ENERGY + + if ( + old_state is None + or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + ): + return + try: # integration as the Riemann integral of previous measures. area = 0 @@ -213,12 +206,12 @@ class IntegrationSensor(RestoreEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return round(self._state, self._round_digits) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 4fd6daa5102..d626daa8c3b 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -6,11 +6,12 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv, integration_platform, intent +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Intent component.""" hass.http.register_view(IntentHandleView()) diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index c1442f0de9f..c3c1ad2b8ce 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -1,6 +1,8 @@ """Support for Home Assistant iOS app sensors.""" +from __future__ import annotations + from homeassistant.components import ios -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -8,10 +10,17 @@ from homeassistant.helpers.icon import icon_for_battery_level from .const import DOMAIN -SENSOR_TYPES = { - "level": ["Battery Level", PERCENTAGE], - "state": ["Battery State", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="level", + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="state", + name="Battery State", + ), +) DEFAULT_ICON_LEVEL = "mdi:battery" DEFAULT_ICON_STATE = "mdi:power-plug" @@ -24,25 +33,30 @@ def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities): """Set up iOS from a config entry.""" - dev = [] - for device_name, device in ios.devices(hass).items(): - for sensor_type in ("level", "state"): - dev.append(IOSSensor(sensor_type, device_name, device)) + entities = [ + IOSSensor(device_name, device, description) + for device_name, device in ios.devices(hass).items() + for description in SENSOR_TYPES + ] - async_add_entities(dev, True) + async_add_entities(entities, True) class IOSSensor(SensorEntity): """Representation of an iOS sensor.""" - def __init__(self, sensor_type, device_name, device): + _attr_should_poll = False + + def __init__(self, device_name, device, description: SensorEntityDescription): """Initialize the sensor.""" - self._device_name = device_name - self._name = f"{device_name} {SENSOR_TYPES[sensor_type][0]}" + self.entity_description = description self._device = device - self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + device_name = device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME] + self._attr_name = f"{device_name} {description.key}" + + device_id = device[ios.ATTR_DEVICE_ID] + self._attr_unique_id = f"{description.key}_{device_id}" @property def device_info(self): @@ -60,33 +74,6 @@ class IOSSensor(SensorEntity): "sw_version": self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_SYSTEM_VERSION], } - @property - def name(self): - """Return the name of the iOS sensor.""" - device_name = self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME] - return f"{device_name} {SENSOR_TYPES[self.type][0]}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - device_id = self._device[ios.ATTR_DEVICE_ID] - return f"{self.type}_{device_id}" - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - @property def extra_state_attributes(self): """Return the device state attributes.""" @@ -119,7 +106,7 @@ class IOSSensor(SensorEntity): charging = False icon_state = f"{DEFAULT_ICON_LEVEL}-unknown" - if self.type == "state": + if self.entity_description.key == "state": return icon_state return icon_for_battery_level(battery_level=battery_level, charging=charging) @@ -127,12 +114,16 @@ class IOSSensor(SensorEntity): def _update(self, device): """Get the latest state of the sensor.""" self._device = device - self._state = self._device[ios.ATTR_BATTERY][self.type] + self._attr_native_value = self._device[ios.ATTR_BATTERY][ + self.entity_description.key + ] self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Added to hass so need to register to dispatch.""" - self._state = self._device[ios.ATTR_BATTERY][self.type] + self._attr_native_value = self._device[ios.ATTR_BATTERY][ + self.entity_description.key + ] device_id = self._device[ios.ATTR_DEVICE_ID] self.async_on_remove( async_dispatcher_connect(self.hass, f"{DOMAIN}.{device_id}", self._update) diff --git a/homeassistant/components/iota/sensor.py b/homeassistant/components/iota/sensor.py index 62260be2410..687a4ca35d6 100644 --- a/homeassistant/components/iota/sensor.py +++ b/homeassistant/components/iota/sensor.py @@ -47,12 +47,12 @@ class IotaBalanceSensor(IotaDevice, SensorEntity): return f"{self._name} Balance" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return "IOTA" @@ -81,7 +81,7 @@ class IotaNodeSensor(IotaDevice, SensorEntity): return "IOTA Node" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/iotawatt/__init__.py b/homeassistant/components/iotawatt/__init__.py new file mode 100644 index 00000000000..7987004e594 --- /dev/null +++ b/homeassistant/components/iotawatt/__init__.py @@ -0,0 +1,24 @@ +"""The iotawatt integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import IotawattUpdater + +PLATFORMS = ("sensor",) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up iotawatt from a config entry.""" + coordinator = IotawattUpdater(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/iotawatt/config_flow.py b/homeassistant/components/iotawatt/config_flow.py new file mode 100644 index 00000000000..9ec860ea76a --- /dev/null +++ b/homeassistant/components/iotawatt/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for iotawatt integration.""" +from __future__ import annotations + +import logging + +from iotawattpy.iotawatt import Iotawatt +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import httpx_client + +from .const import CONNECTION_ERRORS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, str] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + iotawatt = Iotawatt( + "", + data[CONF_HOST], + httpx_client.get_async_client(hass), + data.get(CONF_USERNAME), + data.get(CONF_PASSWORD), + ) + try: + is_connected = await iotawatt.connect() + except CONNECTION_ERRORS: + return {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"} + + if not is_connected: + return {"base": "invalid_auth"} + + return {} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for iotawatt.""" + + VERSION = 1 + + def __init__(self): + """Initialize.""" + self._data = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + user_input = {} + + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + } + ) + if not user_input: + return self.async_show_form(step_id="user", data_schema=schema) + + if not (errors := await validate_input(self.hass, user_input)): + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + + if errors == {"base": "invalid_auth"}: + self._data.update(user_input) + return await self.async_step_auth() + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_auth(self, user_input=None): + """Authenticate user if authentication is enabled on the IoTaWatt device.""" + if user_input is None: + user_input = {} + + data_schema = vol.Schema( + { + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, + } + ) + if not user_input: + return self.async_show_form(step_id="auth", data_schema=data_schema) + + data = {**self._data, **user_input} + + if errors := await validate_input(self.hass, data): + return self.async_show_form( + step_id="auth", data_schema=data_schema, errors=errors + ) + + return self.async_create_entry(title=data[CONF_HOST], data=data) + + +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/iotawatt/const.py b/homeassistant/components/iotawatt/const.py new file mode 100644 index 00000000000..db847f3dfe8 --- /dev/null +++ b/homeassistant/components/iotawatt/const.py @@ -0,0 +1,12 @@ +"""Constants for the IoTaWatt integration.""" +from __future__ import annotations + +import json + +import httpx + +DOMAIN = "iotawatt" +VOLT_AMPERE_REACTIVE = "VAR" +VOLT_AMPERE_REACTIVE_HOURS = "VARh" + +CONNECTION_ERRORS = (KeyError, json.JSONDecodeError, httpx.HTTPError) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py new file mode 100644 index 00000000000..1a722d52a1e --- /dev/null +++ b/homeassistant/components/iotawatt/coordinator.py @@ -0,0 +1,56 @@ +"""IoTaWatt DataUpdateCoordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from iotawattpy.iotawatt import Iotawatt + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import httpx_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONNECTION_ERRORS + +_LOGGER = logging.getLogger(__name__) + + +class IotawattUpdater(DataUpdateCoordinator): + """Class to manage fetching update data from the IoTaWatt Energy Device.""" + + api: Iotawatt | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize IotaWattUpdater object.""" + self.entry = entry + super().__init__( + hass=hass, + logger=_LOGGER, + name=entry.title, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self): + """Fetch sensors from IoTaWatt device.""" + if self.api is None: + api = Iotawatt( + self.entry.title, + self.entry.data[CONF_HOST], + httpx_client.get_async_client(self.hass), + self.entry.data.get(CONF_USERNAME), + self.entry.data.get(CONF_PASSWORD), + ) + try: + is_authenticated = await api.connect() + except CONNECTION_ERRORS as err: + raise UpdateFailed("Connection failed") from err + + if not is_authenticated: + raise UpdateFailed("Authentication error") + + self.api = api + + await self.api.update() + return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json new file mode 100644 index 00000000000..d78e546d71f --- /dev/null +++ b/homeassistant/components/iotawatt/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "iotawatt", + "name": "IoTaWatt", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/iotawatt", + "requirements": [ + "iotawattpy==0.0.8" + ], + "codeowners": [ + "@gtdiehl" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py new file mode 100644 index 00000000000..1b4c166eb27 --- /dev/null +++ b/homeassistant/components/iotawatt/sensor.py @@ -0,0 +1,218 @@ +"""Support for IoTaWatt Energy monitor.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from iotawattpy.sensor import Sensor + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_VOLT_AMPERE, + POWER_WATT, +) +from homeassistant.core import callback +from homeassistant.helpers import entity, entity_registry, update_coordinator +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .coordinator import IotawattUpdater + + +@dataclass +class IotaWattSensorEntityDescription(SensorEntityDescription): + """Class describing IotaWatt sensor entities.""" + + value: Callable | None = None + + +ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { + "Amps": IotaWattSensorEntityDescription( + "Amps", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_CURRENT, + entity_registry_enabled_default=False, + ), + "Hz": IotaWattSensorEntityDescription( + "Hz", + native_unit_of_measurement=FREQUENCY_HERTZ, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + entity_registry_enabled_default=False, + ), + "PF": IotaWattSensorEntityDescription( + "PF", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + "Watts": IotaWattSensorEntityDescription( + "Watts", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER, + ), + "WattHours": IotaWattSensorEntityDescription( + "WattHours", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + "VA": IotaWattSensorEntityDescription( + "VA", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + entity_registry_enabled_default=False, + ), + "VAR": IotaWattSensorEntityDescription( + "VAR", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + entity_registry_enabled_default=False, + ), + "VARh": IotaWattSensorEntityDescription( + "VARh", + native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash", + entity_registry_enabled_default=False, + ), + "Volts": IotaWattSensorEntityDescription( + "Volts", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add sensors for passed config_entry in HA.""" + coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id] + created = set() + + @callback + def _create_entity(key: str) -> IotaWattSensor: + """Create a sensor entity.""" + created.add(key) + 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"), + ), + ) + + async_add_entities(_create_entity(key) for key in coordinator.data["sensors"]) + + @callback + def new_data_received(): + """Check for new sensors.""" + entities = [ + _create_entity(key) + for key in coordinator.data["sensors"] + if key not in created + ] + if entities: + async_add_entities(entities) + + coordinator.async_add_listener(new_data_received) + + +class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): + """Defines a IoTaWatt Energy Sensor.""" + + entity_description: IotaWattSensorEntityDescription + _attr_force_update = True + + def __init__( + self, + coordinator, + key, + mac_address, + name, + entity_description: IotaWattSensorEntityDescription, + ): + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + + self._key = key + data = self._sensor_data + if data.getType() == "Input": + self._attr_unique_id = ( + f"{data.hub_mac_address}-input-{data.getChannel()}-{data.getUnit()}" + ) + self.entity_description = entity_description + + @property + def _sensor_data(self) -> Sensor: + """Return sensor data.""" + return self.coordinator.data["sensors"][self._key] + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return self._sensor_data.getName() + + @property + def device_info(self) -> entity.DeviceInfo | None: + """Return device info.""" + return { + "connections": { + (CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) + }, + "manufacturer": "IoTaWatt", + "model": "IoTaWatt", + } + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._key not in self.coordinator.data["sensors"]: + if self._attr_unique_id: + entity_registry.async_get(self.hass).async_remove(self.entity_id) + else: + self.hass.async_create_task(self.async_remove()) + return + + super()._handle_coordinator_update() + + @property + def extra_state_attributes(self): + """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 + def native_value(self) -> entity.StateType: + """Return the state of the sensor.""" + if func := self.entity_description.value: + return func(self._sensor_data.getValue()) + + return self._sensor_data.getValue() diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json new file mode 100644 index 00000000000..f21dfe0cd09 --- /dev/null +++ b/homeassistant/components/iotawatt/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "auth": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/iotawatt/translations/en.json b/homeassistant/components/iotawatt/translations/en.json new file mode 100644 index 00000000000..cbda4b41bea --- /dev/null +++ b/homeassistant/components/iotawatt/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "auth": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "title": "iotawatt" +} \ No newline at end of file diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 610ff91250f..07b9cc069e4 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -41,12 +41,12 @@ class Iperf3Sensor(RestoreEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 65d326f8f3a..242390b55b8 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -1,8 +1,6 @@ """The Internet Printing Protocol (IPP) integration.""" from __future__ import annotations -import logging - from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL @@ -13,8 +11,6 @@ from .coordinator import IPPDataUpdateCoordinator PLATFORMS = [SENSOR_DOMAIN] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IPP from a config entry.""" diff --git a/homeassistant/components/ipp/const.py b/homeassistant/components/ipp/const.py index d482f2d73e4..3501759074f 100644 --- a/homeassistant/components/ipp/const.py +++ b/homeassistant/components/ipp/const.py @@ -5,15 +5,11 @@ DOMAIN = "ipp" # Attributes ATTR_COMMAND_SET = "command_set" -ATTR_IDENTIFIERS = "identifiers" ATTR_INFO = "info" -ATTR_MANUFACTURER = "manufacturer" ATTR_MARKER_TYPE = "marker_type" ATTR_MARKER_LOW_LEVEL = "marker_low_level" ATTR_MARKER_HIGH_LEVEL = "marker_high_level" -ATTR_MODEL = "model" ATTR_SERIAL = "serial" -ATTR_SOFTWARE_VERSION = "sw_version" ATTR_STATE_MESSAGE = "state_message" ATTR_STATE_REASON = "state_reason" ATTR_URI_SUPPORTED = "uri_supported" diff --git a/homeassistant/components/ipp/entity.py b/homeassistant/components/ipp/entity.py index 0038bbd7370..55a0e76a658 100644 --- a/homeassistant/components/ipp/entity.py +++ b/homeassistant/components/ipp/entity.py @@ -1,17 +1,17 @@ """Entities for The Internet Printing Protocol (IPP) integration.""" from __future__ import annotations -from homeassistant.const import ATTR_NAME -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import ( +from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, - ATTR_SOFTWARE_VERSION, - DOMAIN, + ATTR_NAME, + ATTR_SW_VERSION, ) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN from .coordinator import IPPDataUpdateCoordinator @@ -47,5 +47,5 @@ class IPPEntity(CoordinatorEntity): ATTR_NAME: self.coordinator.data.info.name, ATTR_MANUFACTURER: self.coordinator.data.info.manufacturer, ATTR_MODEL: self.coordinator.data.info.model, - ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version, + ATTR_SW_VERSION: self.coordinator.data.info.version, } diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 5d736c864e1..e7c0d5c38f5 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -72,7 +72,7 @@ class IPPSensor(IPPEntity, SensorEntity): """Initialize IPP sensor.""" self._key = key self._attr_unique_id = f"{unique_id}_{key}" - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement super().__init__( entry_id=entry_id, @@ -123,7 +123,7 @@ class IPPMarkerSensor(IPPSensor): } @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" level = self.coordinator.data.markers[self.marker_index].level @@ -164,7 +164,7 @@ class IPPPrinterSensor(IPPSensor): } @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" return self.coordinator.data.state.printer_state @@ -189,7 +189,7 @@ class IPPUptimeSensor(IPPSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) return uptime.replace(microsecond=0).isoformat() diff --git a/homeassistant/components/ipp/translations/ca.json b/homeassistant/components/ipp/translations/ca.json index e3669e6d458..6d91535942a 100644 --- a/homeassistant/components/ipp/translations/ca.json +++ b/homeassistant/components/ipp/translations/ca.json @@ -23,7 +23,7 @@ "ssl": "Utilitza un certificat SSL", "verify_ssl": "Verifica el certificat SSL" }, - "description": "Configura la impressora amb el protocol d'impressi\u00f3 per Internet (IPP) per integrar-la amb Home Assistant.", + "description": "Configura la integraci\u00f3 amb Home Assistant d'una impressora amb protocol d'impressi\u00f3 per Internet (IPP).", "title": "Enlla\u00e7 d'impressora" }, "zeroconf_confirm": { diff --git a/homeassistant/components/ipp/translations/hu.json b/homeassistant/components/ipp/translations/hu.json index 8c988eff551..a024cfb2e56 100644 --- a/homeassistant/components/ipp/translations/hu.json +++ b/homeassistant/components/ipp/translations/hu.json @@ -3,7 +3,11 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "connection_upgrade": "Nem siker\u00fclt csatlakozni a nyomtat\u00f3hoz, mert a kapcsolat friss\u00edt\u00e9se sz\u00fcks\u00e9ges." + "connection_upgrade": "Nem siker\u00fclt csatlakozni a nyomtat\u00f3hoz, mert a kapcsolat friss\u00edt\u00e9se sz\u00fcks\u00e9ges.", + "ipp_error": "IPP hiba t\u00f6rt\u00e9nt.", + "ipp_version_error": "A nyomtat\u00f3 nem t\u00e1mogatja az IPP verzi\u00f3t.", + "parse_error": "Nem siker\u00fclt elemezni a nyomtat\u00f3 v\u00e1lasz\u00e1t.", + "unique_id_required": "Az eszk\u00f6zb\u0151l hi\u00e1nyzik a felfedez\u00e9shez sz\u00fcks\u00e9ges egyedi azonos\u00edt\u00f3." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -13,14 +17,18 @@ "step": { "user": { "data": { + "base_path": "Relat\u00edv \u00fatvonal a nyomtat\u00f3hoz", "host": "Hoszt", "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.", + "title": "Kapcsolja \u00f6ssze a nyomtat\u00f3t" }, "zeroconf_confirm": { - "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?" + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?", + "title": "Felfedezett nyomtat\u00f3" } } } diff --git a/homeassistant/components/ipp/translations/zh-Hans.json b/homeassistant/components/ipp/translations/zh-Hans.json index 254f6df9327..38242cae563 100644 --- a/homeassistant/components/ipp/translations/zh-Hans.json +++ b/homeassistant/components/ipp/translations/zh-Hans.json @@ -1,10 +1,25 @@ { "config": { "abort": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "ipp_version_error": "\u6253\u5370\u673a\u4e0d\u652f\u6301\u8be5 IPP \u7248\u672c", + "parse_error": "\u65e0\u6cd5\u89e3\u6790\u6253\u5370\u673a\u54cd\u5e94\u3002" }, "error": { - "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "connection_upgrade": "\u65e0\u6cd5\u8fde\u63a5\u5230\u6253\u5370\u673a\u3002\u8bf7\u9009\u4e2d SSL/TLS \u9009\u9879\u540e\u91cd\u8bd5\u3002" + }, + "step": { + "user": { + "data": { + "base_path": "\u6253\u5370\u673a\u7684\u76f8\u5bf9\u8def\u5f84" + }, + "description": "\u901a\u8fc7 Internet \u6253\u5370\u534f\u8bae (IPP) \u8bbe\u7f6e\u60a8\u7684\u6253\u5370\u673a\uff0c\u4e0e Home Assistant \u8fde\u63a5\u3002", + "title": "\u8fde\u63a5\u60a8\u7684\u6253\u5370\u673a" + }, + "zeroconf_confirm": { + "title": "\u5df2\u53d1\u73b0\u7684\u6253\u5370\u673a" + } } } } \ No newline at end of file diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index fa783cc9031..37cc7bedb71 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -109,7 +109,7 @@ class IQVIAEntity(CoordinatorEntity, SensorEntity): self._attr_icon = icon self._attr_name = name self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{sensor_type}" - self._attr_unit_of_measurement = "index" + self._attr_native_unit_of_measurement = "index" self._entry = entry self._type = sensor_type diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index da50819c9a0..e8914507657 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.0.0"], + "requirements": ["numpy==1.21.1", "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 0ff236a8f79..10d33bfb4bf 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -104,7 +104,7 @@ class ForecastSensor(IQVIAEntity): @callback def update_from_latest_data(self): """Update the sensor.""" - if not self.coordinator.data: + if not self.available: return data = self.coordinator.data.get("Location", {}) @@ -120,6 +120,7 @@ class ForecastSensor(IQVIAEntity): if i["minimum"] <= average <= i["maximum"] ] + self._attr_native_value = average self._attr_extra_state_attributes.update( { ATTR_CITY: data["City"].title(), @@ -134,6 +135,10 @@ class ForecastSensor(IQVIAEntity): outlook_coordinator = self.hass.data[DOMAIN][DATA_COORDINATOR][ self._entry.entry_id ][TYPE_ALLERGY_OUTLOOK] + + if not outlook_coordinator.last_update_success: + return + self._attr_extra_state_attributes[ ATTR_OUTLOOK ] = outlook_coordinator.data.get("Outlook") @@ -141,8 +146,6 @@ class ForecastSensor(IQVIAEntity): ATTR_SEASON ] = outlook_coordinator.data.get("Season") - self._attr_state = average - class IndexSensor(IQVIAEntity): """Define sensor related to indices.""" @@ -210,4 +213,4 @@ class IndexSensor(IQVIAEntity): f"{attrs['Name'].lower()}_index" ] = attrs["Index"] - self._attr_state = period["Index"] + self._attr_native_value = period["Index"] diff --git a/homeassistant/components/iqvia/translations/hu.json b/homeassistant/components/iqvia/translations/hu.json index f5301e874ea..0ae420e47aa 100644 --- a/homeassistant/components/iqvia/translations/hu.json +++ b/homeassistant/components/iqvia/translations/hu.json @@ -2,6 +2,18 @@ "config": { "abort": { "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "invalid_zip_code": "Az ir\u00e1ny\u00edt\u00f3sz\u00e1m \u00e9rv\u00e9nytelen" + }, + "step": { + "user": { + "data": { + "zip_code": "Ir\u00e1ny\u00edt\u00f3sz\u00e1m" + }, + "description": "T\u00f6ltse ki amerikai vagy kanadai ir\u00e1ny\u00edt\u00f3sz\u00e1m\u00e1t.", + "title": "IQVIA" + } } } } \ No newline at end of file diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index b5ba16f8541..9ec28d73836 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -83,7 +83,7 @@ class IrishRailTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -114,7 +114,7 @@ class IrishRailTransportSensor(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 2fa563785d2..99cc65bb548 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -43,7 +43,7 @@ class IslamicPrayerTimeSensor(SensorEntity): return self.sensor_type @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return ( self.client.prayer_times_info.get(self.sensor_type) diff --git a/homeassistant/components/islamic_prayer_times/translations/hu.json b/homeassistant/components/islamic_prayer_times/translations/hu.json index 065747fb39d..5bad8174b9a 100644 --- a/homeassistant/components/islamic_prayer_times/translations/hu.json +++ b/homeassistant/components/islamic_prayer_times/translations/hu.json @@ -2,6 +2,22 @@ "config": { "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "user": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani az iszl\u00e1m imaid\u0151ket?", + "title": "\u00c1ll\u00edtsa be az iszl\u00e1m imaid\u0151t" + } } - } + }, + "options": { + "step": { + "init": { + "data": { + "calculation_method": "Az ima sz\u00e1m\u00edt\u00e1si m\u00f3dszere" + } + } + } + }, + "title": "Iszl\u00e1m ima id\u0151k" } \ No newline at end of file diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 4a259dac6d8..58997eaa579 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -184,20 +184,14 @@ def _detect_device_type_and_class(node: Group | Node) -> (str, str): # Z-Wave Devices: if node.protocol == PROTO_ZWAVE: device_type = f"Z{node.zwave_props.category}" - for device_class in [*BINARY_SENSOR_DEVICE_TYPES_ZWAVE]: - if ( - node.zwave_props.category - in BINARY_SENSOR_DEVICE_TYPES_ZWAVE[device_class] - ): + for device_class, values in BINARY_SENSOR_DEVICE_TYPES_ZWAVE.items(): + if node.zwave_props.category in values: return device_class, device_type return (None, device_type) # Other devices (incl Insteon.) - for device_class in [*BINARY_SENSOR_DEVICE_TYPES_ISY]: - if any( - device_type.startswith(t) - for t in set(BINARY_SENSOR_DEVICE_TYPES_ISY[device_class]) - ): + for device_class, values in BINARY_SENSOR_DEVICE_TYPES_ISY.items(): + if any(device_type.startswith(t) for t in values): return device_class, device_type return (None, device_type) @@ -397,7 +391,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity): The ISY uses both DON and DOF commands (alternating) for a heartbeat. """ - if event.control in [CMD_ON, CMD_OFF]: + if event.control in (CMD_ON, CMD_OFF): self.async_heartbeat() @callback diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index b9b1a71901c..d1790fcc13c 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -114,10 +114,10 @@ def _check_for_insteon_type( return True # Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3 - if platform == CLIMATE and subnode_id in [ + if platform == CLIMATE and subnode_id in ( SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, - ]: + ): hass_isy_data[ISY994_NODES][BINARY_SENSOR].append(node) return True @@ -184,7 +184,7 @@ def _check_for_uom_id( This is used for versions of the ISY firmware that report uoms as a single ID. We can often infer what type of device it is by that ID. """ - if not hasattr(node, "uom") or node.uom in [None, ""]: + if not hasattr(node, "uom") or node.uom in (None, ""): # Node doesn't have a uom (Scenes for example) return False @@ -220,7 +220,7 @@ def _check_for_states_in_uom( possible "human readable" states. This filter passes if all of the possible states fit inside the given filter. """ - if not hasattr(node, "uom") or node.uom in [None, ""]: + if not hasattr(node, "uom") or node.uom in (None, ""): # Node doesn't have a uom (Scenes for example) return False @@ -413,7 +413,7 @@ def convert_isy_value_to_hass( """ if value is None or value == ISY_VALUE_UNKNOWN: return None - if uom in [UOM_DOUBLE_TEMP, UOM_ISYV4_DEGREES]: + if uom in (UOM_DOUBLE_TEMP, UOM_ISYV4_DEGREES): return round(float(value) / 2.0, 1) if precision not in ("0", 0): return round(float(value) / 10 ** int(precision), int(precision)) diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 79c5663f964..f12f3cb6bdd 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -61,13 +61,13 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): if isy_states: return isy_states - if uom in [UOM_ON_OFF, UOM_INDEX]: + if uom in (UOM_ON_OFF, UOM_INDEX): return uom return UOM_FRIENDLY_NAME.get(uom) @property - def state(self) -> str: + def native_value(self) -> str: """Get the state of the ISY994 sensor device.""" value = self._node.status if value == ISY_VALUE_UNKNOWN: @@ -80,7 +80,7 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): if isinstance(uom, dict): return uom.get(value, value) - if uom in [UOM_INDEX, UOM_ON_OFF]: + if uom in (UOM_INDEX, UOM_ON_OFF): return self._node.formatted # Check if this is an index type and get formatted value @@ -97,11 +97,11 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): return value @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Get the Home Assistant unit of measurement for the device.""" raw_units = self.raw_unit_of_measurement # Check if this is a known index pair UOM - if isinstance(raw_units, dict) or raw_units in [UOM_ON_OFF, UOM_INDEX]: + if isinstance(raw_units, dict) or raw_units in (UOM_ON_OFF, UOM_INDEX): return None if raw_units in (TEMP_FAHRENHEIT, TEMP_CELSIUS, UOM_DOUBLE_TEMP): return self.hass.config.units.temperature_unit @@ -117,7 +117,7 @@ class ISYSensorVariableEntity(ISYEntity, SensorEntity): self._name = vname @property - def state(self): + def native_value(self): """Return the state of the variable.""" return convert_isy_value_to_hass(self._node.status, "", self._node.prec) diff --git a/homeassistant/components/isy994/translations/he.json b/homeassistant/components/isy994/translations/he.json index 2485285743f..b1874d2675d 100644 --- a/homeassistant/components/isy994/translations/he.json +++ b/homeassistant/components/isy994/translations/he.json @@ -21,6 +21,15 @@ } } }, + "options": { + "step": { + "init": { + "data": { + "restore_light_state": "\u05e9\u05d7\u05d6\u05d5\u05e8 \u05d1\u05d4\u05d9\u05e8\u05d5\u05ea \u05d4\u05ea\u05d0\u05d5\u05e8\u05d4" + } + } + } + }, "system_health": { "info": { "host_reachable": "\u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d2\u05d9\u05e2 \u05dc\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/isy994/translations/hu.json b/homeassistant/components/isy994/translations/hu.json index 065be706d0f..dab85300e6d 100644 --- a/homeassistant/components/isy994/translations/hu.json +++ b/homeassistant/components/isy994/translations/hu.json @@ -6,6 +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", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name} ({host})", @@ -14,14 +15,24 @@ "data": { "host": "URL", "password": "Jelsz\u00f3", + "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", + "title": "Csatlakozzon az ISY994-hez" } } }, "options": { "step": { "init": { + "data": { + "ignore_string": "Figyelmen k\u00edv\u00fcl hagyja a karakterl\u00e1ncot", + "restore_light_state": "F\u00e9nyer\u0151 vissza\u00e1ll\u00edt\u00e1sa", + "sensor_string": "Csom\u00f3pont \u00e9rz\u00e9kel\u0151 karakterl\u00e1nc", + "variable_sensor_string": "V\u00e1ltoz\u00f3 \u00e9rz\u00e9kel\u0151 karakterl\u00e1nc" + }, + "description": "\u00c1ll\u00edtsa be az ISY integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sait:\n \u2022 Csom\u00f3pont -\u00e9rz\u00e9kel\u0151 karakterl\u00e1nc: B\u00e1rmely eszk\u00f6z vagy mappa, amelynek nev\u00e9ben \u201eNode Sensor String\u201d szerepel, \u00e9rz\u00e9kel\u0151k\u00e9nt vagy bin\u00e1ris \u00e9rz\u00e9kel\u0151k\u00e9nt fog kezelni.\n \u2022 Karakterl\u00e1nc figyelmen k\u00edv\u00fcl hagy\u00e1sa: Minden olyan eszk\u00f6z, amelynek a neve \u201eIgnore String\u201d, figyelmen k\u00edv\u00fcl marad.\n \u2022 V\u00e1ltoz\u00f3 \u00e9rz\u00e9kel\u0151 karakterl\u00e1nc: B\u00e1rmely v\u00e1ltoz\u00f3, amely tartalmazza a \u201eV\u00e1ltoz\u00f3 \u00e9rz\u00e9kel\u0151 karakterl\u00e1ncot\u201d, hozz\u00e1ad\u00f3dik \u00e9rz\u00e9kel\u0151k\u00e9nt.\n \u2022 F\u00e9nyer\u0151ss\u00e9g vissza\u00e1ll\u00edt\u00e1sa: Ha enged\u00e9lyezve van, akkor az el\u0151z\u0151 f\u00e9nyer\u0151 vissza\u00e1ll, amikor a k\u00e9sz\u00fcl\u00e9ket be\u00e9p\u00edtett On-Level helyett bekapcsolja.", "title": "ISY994 Be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 76744550649..e3f4b62af63 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -26,7 +26,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" conf = config.get(IZONE) if not conf: diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 35c1505561d..bb7cc9d2841 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -10,39 +10,6 @@ from homeassistant.helpers.discovery import async_load_platform DOMAIN = "jewish_calendar" -SENSOR_TYPES = { - "binary": { - "issur_melacha_in_effect": ["Issur Melacha in Effect", "mdi:power-plug-off"] - }, - "data": { - "date": ["Date", "mdi:judaism"], - "weekly_portion": ["Parshat Hashavua", "mdi:book-open-variant"], - "holiday": ["Holiday", "mdi:calendar-star"], - "omer_count": ["Day of the Omer", "mdi:counter"], - "daf_yomi": ["Daf Yomi", "mdi:book-open-variant"], - }, - "time": { - "first_light": ["Alot Hashachar", "mdi:weather-sunset-up"], - "talit": ["Talit and Tefillin", "mdi:calendar-clock"], - "gra_end_shma": ['Latest time for Shma Gr"a', "mdi:calendar-clock"], - "mga_end_shma": ['Latest time for Shma MG"A', "mdi:calendar-clock"], - "gra_end_tfila": ['Latest time for Tefilla Gr"a', "mdi:calendar-clock"], - "mga_end_tfila": ['Latest time for Tefilla MG"A', "mdi:calendar-clock"], - "big_mincha": ["Mincha Gedola", "mdi:calendar-clock"], - "small_mincha": ["Mincha Ketana", "mdi:calendar-clock"], - "plag_mincha": ["Plag Hamincha", "mdi:weather-sunset-down"], - "sunset": ["Shkia", "mdi:weather-sunset"], - "first_stars": ["T'set Hakochavim", "mdi:weather-night"], - "upcoming_shabbat_candle_lighting": [ - "Upcoming Shabbat Candle Lighting", - "mdi:candle", - ], - "upcoming_shabbat_havdalah": ["Upcoming Shabbat Havdalah", "mdi:weather-night"], - "upcoming_candle_lighting": ["Upcoming Candle Lighting", "mdi:candle"], - "upcoming_havdalah": ["Upcoming Havdalah", "mdi:weather-night"], - }, -} - CONF_DIASPORA = "diaspora" CONF_LANGUAGE = "language" CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 954b22debd0..c4e9b1e347f 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -1,40 +1,51 @@ """Support for Jewish Calendar binary sensors.""" +from __future__ import annotations + import datetime as dt import hdate -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import callback +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DOMAIN, SENSOR_TYPES +from . import DOMAIN + +BINARY_SENSORS = BinarySensorEntityDescription( + key="issur_melacha_in_effect", + name="Issur Melacha in Effect", + icon="mdi:power-plug-off", +) -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, +): """Set up the Jewish Calendar binary sensor devices.""" if discovery_info is None: return - async_add_entities( - [ - JewishCalendarBinarySensor(hass.data[DOMAIN], sensor, sensor_info) - for sensor, sensor_info in SENSOR_TYPES["binary"].items() - ] - ) + async_add_entities([JewishCalendarBinarySensor(hass.data[DOMAIN], BINARY_SENSORS)]) class JewishCalendarBinarySensor(BinarySensorEntity): """Representation of an Jewish Calendar binary sensor.""" - def __init__(self, data, sensor, sensor_info): + _attr_should_poll = False + + def __init__(self, data, description: BinarySensorEntityDescription) -> None: """Initialize the binary sensor.""" - self._type = sensor - self._prefix = data["prefix"] - self._attr_name = f"{data['name']} {sensor_info[0]}" - self._attr_unique_id = f"{self._prefix}_{self._type}" - self._attr_icon = sensor_info[1] - self._attr_should_poll = False + self._attr_name = f"{data['name']} {description.name}" + self._attr_unique_id = f"{data['prefix']}_{description.key}" self._location = data["location"] self._hebrew = data["language"] == "hebrew" self._candle_lighting_offset = data["candle_lighting_offset"] @@ -42,7 +53,7 @@ class JewishCalendarBinarySensor(BinarySensorEntity): self._update_unsub = None @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor is on.""" return self._get_zmanim().issur_melacha_in_effect @@ -56,7 +67,7 @@ class JewishCalendarBinarySensor(BinarySensorEntity): hebrew=self._hebrew, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() self._schedule_update() diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 17a61c932a3..4e90dd00058 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -1,31 +1,147 @@ """Platform to retrieve Jewish calendar information for Home Assistant.""" +from __future__ import annotations + from datetime import datetime import logging import hdate -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import DEVICE_CLASS_TIMESTAMP, SUN_EVENT_SUNSET +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DOMAIN, SENSOR_TYPES +from . import DOMAIN _LOGGER = logging.getLogger(__name__) +DATA_SENSORS = ( + SensorEntityDescription( + key="date", + name="Date", + icon="mdi:judaism", + ), + SensorEntityDescription( + key="weekly_portion", + name="Parshat Hashavua", + icon="mdi:book-open-variant", + ), + SensorEntityDescription( + key="holiday", + name="Holiday", + icon="mdi:calendar-star", + ), + SensorEntityDescription( + key="omer_count", + name="Day of the Omer", + icon="mdi:counter", + ), + SensorEntityDescription( + key="daf_yomi", + name="Daf Yomi", + icon="mdi:book-open-variant", + ), +) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +TIME_SENSORS = ( + SensorEntityDescription( + key="first_light", + name="Alot Hashachar", + icon="mdi:weather-sunset-up", + ), + SensorEntityDescription( + key="talit", + name="Talit and Tefillin", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="gra_end_shma", + name='Latest time for Shma Gr"a', + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="mga_end_shma", + name='Latest time for Shma MG"A', + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="gra_end_tfila", + name='Latest time for Tefilla Gr"a', + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="mga_end_tfila", + name='Latest time for Tefilla MG"A', + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="big_mincha", + name="Mincha Gedola", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="small_mincha", + name="Mincha Ketana", + icon="mdi:calendar-clock", + ), + SensorEntityDescription( + key="plag_mincha", + name="Plag Hamincha", + icon="mdi:weather-sunset-down", + ), + SensorEntityDescription( + key="sunset", + name="Shkia", + icon="mdi:weather-sunset", + ), + SensorEntityDescription( + key="first_stars", + name="T'set Hakochavim", + icon="mdi:weather-night", + ), + SensorEntityDescription( + key="upcoming_shabbat_candle_lighting", + name="Upcoming Shabbat Candle Lighting", + icon="mdi:candle", + ), + SensorEntityDescription( + key="upcoming_shabbat_havdalah", + name="Upcoming Shabbat Havdalah", + icon="mdi:weather-night", + ), + SensorEntityDescription( + key="upcoming_candle_lighting", + name="Upcoming Candle Lighting", + icon="mdi:candle", + ), + SensorEntityDescription( + key="upcoming_havdalah", + name="Upcoming Havdalah", + icon="mdi:weather-night", + ), +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +): """Set up the Jewish calendar sensor platform.""" if discovery_info is None: return sensors = [ - JewishCalendarSensor(hass.data[DOMAIN], sensor, sensor_info) - for sensor, sensor_info in SENSOR_TYPES["data"].items() + JewishCalendarSensor(hass.data[DOMAIN], description) + for description in DATA_SENSORS ] sensors.extend( - JewishCalendarTimeSensor(hass.data[DOMAIN], sensor, sensor_info) - for sensor, sensor_info in SENSOR_TYPES["time"].items() + JewishCalendarTimeSensor(hass.data[DOMAIN], description) + for description in TIME_SENSORS ) async_add_entities(sensors) @@ -34,23 +150,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class JewishCalendarSensor(SensorEntity): """Representation of an Jewish calendar sensor.""" - def __init__(self, data, sensor, sensor_info): + def __init__(self, data, description: SensorEntityDescription) -> None: """Initialize the Jewish calendar sensor.""" - self._type = sensor - self._prefix = data["prefix"] - self._attr_name = f"{data['name']} {sensor_info[0]}" - self._attr_unique_id = f"{self._prefix}_{self._type}" - self._attr_icon = sensor_info[1] + self.entity_description = description + self._attr_name = f"{data['name']} {description.name}" + self._attr_unique_id = f"{data['prefix']}_{description.key}" self._location = data["location"] self._hebrew = data["language"] == "hebrew" self._candle_lighting_offset = data["candle_lighting_offset"] self._havdalah_offset = data["havdalah_offset"] self._diaspora = data["diaspora"] self._state = None - self._holiday_attrs = {} + self._holiday_attrs: dict[str, str] = {} @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if isinstance(self._state, datetime): return self._state.isoformat() @@ -87,7 +201,7 @@ class JewishCalendarSensor(SensorEntity): after_tzais_date = daytime_date.next_day self._state = self.get_state(daytime_date, after_shkia_date, after_tzais_date) - _LOGGER.debug("New value for %s: %s", self._type, self._state) + _LOGGER.debug("New value for %s: %s", self.entity_description.key, self._state) def make_zmanim(self, date): """Create a Zmanim object.""" @@ -100,9 +214,9 @@ class JewishCalendarSensor(SensorEntity): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - if self._type != "holiday": + if self.entity_description.key != "holiday": return {} return self._holiday_attrs @@ -110,19 +224,19 @@ class JewishCalendarSensor(SensorEntity): """For a given type of sensor, return the state.""" # Terminology note: by convention in py-libhdate library, "upcoming" # refers to "current" or "upcoming" dates. - if self._type == "date": + if self.entity_description.key == "date": return after_shkia_date.hebrew_date - if self._type == "weekly_portion": + if self.entity_description.key == "weekly_portion": # Compute the weekly portion based on the upcoming shabbat. return after_tzais_date.upcoming_shabbat.parasha - if self._type == "holiday": + 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 return after_shkia_date.holiday_description - if self._type == "omer_count": + if self.entity_description.key == "omer_count": return after_shkia_date.omer_day - if self._type == "daf_yomi": + if self.entity_description.key == "daf_yomi": return daytime_date.daf_yomi return None @@ -134,16 +248,16 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): _attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._state is None: return None return dt_util.as_utc(self._state).isoformat() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - attrs = {} + attrs: dict[str, str] = {} if self._state is None: return attrs @@ -152,24 +266,24 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): def get_state(self, daytime_date, after_shkia_date, after_tzais_date): """For a given type of sensor, return the state.""" - if self._type == "upcoming_shabbat_candle_lighting": + if self.entity_description.key == "upcoming_shabbat_candle_lighting": times = self.make_zmanim( after_tzais_date.upcoming_shabbat.previous_day.gdate ) return times.candle_lighting - if self._type == "upcoming_candle_lighting": + if self.entity_description.key == "upcoming_candle_lighting": times = self.make_zmanim( after_tzais_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate ) return times.candle_lighting - if self._type == "upcoming_shabbat_havdalah": + if self.entity_description.key == "upcoming_shabbat_havdalah": times = self.make_zmanim(after_tzais_date.upcoming_shabbat.gdate) return times.havdalah - if self._type == "upcoming_havdalah": + if self.entity_description.key == "upcoming_havdalah": times = self.make_zmanim( after_tzais_date.upcoming_shabbat_or_yom_tov.last_day.gdate ) return times.havdalah times = self.make_zmanim(dt_util.now()).zmanim - return times[self._type] + return times[self.entity_description.key] diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 38089a6e17f..0480eac80b3 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR @@ -30,7 +31,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the JuiceNet component.""" conf = config.get(DOMAIN) hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index 759979c5f11..9b1def3b678 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -14,11 +14,6 @@ class JuiceNetDevice(CoordinatorEntity): self.device = device self.type = sensor_type - @property - def name(self): - """Return the name of the device.""" - return self.device.name - @property def unique_id(self): """Return a unique ID.""" diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 51792daf38c..4eaaba41b55 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -1,5 +1,11 @@ """Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -17,61 +23,81 @@ from homeassistant.const import ( from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR from .entity import JuiceNetDevice -SENSOR_TYPES = { - "status": ["Charging Status", None, None, None], - "temperature": [ - "Temperature", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, - ], - "voltage": ["Voltage", ELECTRIC_POTENTIAL_VOLT, DEVICE_CLASS_VOLTAGE, None], - "amps": [ - "Amps", - ELECTRIC_CURRENT_AMPERE, - DEVICE_CLASS_CURRENT, - STATE_CLASS_MEASUREMENT, - ], - "watts": ["Watts", POWER_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT], - "charge_time": ["Charge time", TIME_SECONDS, None, None], - "energy_added": ["Energy added", ENERGY_WATT_HOUR, DEVICE_CLASS_ENERGY, None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="status", + name="Charging Status", + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage", + name="Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + SensorEntityDescription( + key="amps", + name="Amps", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="watts", + name="Watts", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="charge_time", + name="Charge time", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="energy_added", + name="Energy added", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the JuiceNet Sensors.""" - entities = [] juicenet_data = hass.data[DOMAIN][config_entry.entry_id] api = juicenet_data[JUICENET_API] coordinator = juicenet_data[JUICENET_COORDINATOR] - for device in api.devices: - for sensor in SENSOR_TYPES: - entities.append(JuiceNetSensorDevice(device, sensor, coordinator)) + entities = [ + JuiceNetSensorDevice(device, coordinator, description) + for device in api.devices + for description in SENSOR_TYPES + ] async_add_entities(entities) class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): """Implementation of a JuiceNet sensor.""" - def __init__(self, device, sensor_type, coordinator): + def __init__(self, device, coordinator, description: SensorEntityDescription): """Initialise the sensor.""" - super().__init__(device, sensor_type, coordinator) - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - self._attr_state_class = SENSOR_TYPES[sensor_type][3] - - @property - def name(self): - """Return the name of the device.""" - return f"{self.device.name} {self._name}" + super().__init__(device, description.key, coordinator) + self.entity_description = description + self._attr_name = f"{self.device.name} {description.name}" @property def icon(self): """Return the icon of the sensor.""" icon = None - if self.type == "status": + if self.entity_description.key == "status": status = self.device.status if status == "standby": icon = "mdi:power-plug-off" @@ -79,43 +105,11 @@ class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): icon = "mdi:power-plug" elif status == "charging": icon = "mdi:battery-positive" - elif self.type == "temperature": - icon = "mdi:thermometer" - elif self.type == "voltage": - icon = "mdi:flash" - elif self.type == "amps": - icon = "mdi:flash" - elif self.type == "watts": - icon = "mdi:flash" - elif self.type == "charge_time": - icon = "mdi:timer-outline" - elif self.type == "energy_added": - icon = "mdi:flash" + else: + icon = self.entity_description.icon return icon @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def state(self): + def native_value(self): """Return the state.""" - state = None - if self.type == "status": - state = self.device.status - elif self.type == "temperature": - state = self.device.temperature - elif self.type == "voltage": - state = self.device.voltage - elif self.type == "amps": - state = self.device.amps - elif self.type == "watts": - state = self.device.watts - elif self.type == "charge_time": - state = self.device.charge_time - elif self.type == "energy_added": - state = self.device.energy_added - else: - state = "Unknown" - return state + return getattr(self.device, self.entity_description.key, None) diff --git a/homeassistant/components/juicenet/translations/hu.json b/homeassistant/components/juicenet/translations/hu.json index f04a8c1e6ca..63e6086190b 100644 --- a/homeassistant/components/juicenet/translations/hu.json +++ b/homeassistant/components/juicenet/translations/hu.json @@ -13,6 +13,7 @@ "data": { "api_token": "API Token" }, + "description": "Sz\u00fcks\u00e9ge lesz az API Tokenre a https://home.juice.net/Manage webhelyen.", "title": "Csatlakoz\u00e1s a JuiceNethez" } } diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py index 6c82013361a..fbaa730ab9f 100644 --- a/homeassistant/components/kaiterra/sensor.py +++ b/homeassistant/components/kaiterra/sensor.py @@ -70,7 +70,7 @@ class KaiterraSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state.""" return self._sensor.get("value") @@ -80,7 +80,7 @@ class KaiterraSensor(SensorEntity): return f"{self._device_id}_{self._kind}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if not self._sensor.get("units"): return None diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 2792246d71c..fc761074bc7 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -1,12 +1,22 @@ """Support for KEBA charging station sensors.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( +from __future__ import annotations + +from homeassistant.components.sensor import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, + POWER_KILO_WATT, ) -from . import DOMAIN +from . import DOMAIN, KebaHandler async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -19,44 +29,55 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensors = [ KebaSensor( keba, - "Curr user", - "Max Current", "max_current", - "mdi:flash", - ELECTRIC_CURRENT_AMPERE, + SensorEntityDescription( + key="Curr user", + name="Max Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + ), ), KebaSensor( keba, - "Setenergy", - "Energy Target", "energy_target", - "mdi:gauge", - ENERGY_KILO_WATT_HOUR, + SensorEntityDescription( + key="Setenergy", + name="Energy Target", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), ), KebaSensor( keba, - "P", - "Charging Power", "charging_power", - "mdi:flash", - "kW", - DEVICE_CLASS_POWER, + SensorEntityDescription( + key="P", + name="Charging Power", + native_unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), ), KebaSensor( keba, - "E pres", - "Session Energy", "session_energy", - "mdi:gauge", - ENERGY_KILO_WATT_HOUR, + SensorEntityDescription( + key="E pres", + name="Session Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), ), KebaSensor( keba, - "E total", - "Total Energy", "total_energy", - "mdi:gauge", - ENERGY_KILO_WATT_HOUR, + SensorEntityDescription( + key="E total", + name="Total Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), ), ] async_add_entities(sensors) @@ -65,53 +86,22 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class KebaSensor(SensorEntity): """The entity class for KEBA charging stations sensors.""" - def __init__(self, keba, key, name, entity_type, icon, unit, device_class=None): + _attr_should_poll = False + + def __init__( + self, + keba: KebaHandler, + entity_type: str, + description: SensorEntityDescription, + ) -> None: """Initialize the KEBA Sensor.""" self._keba = keba - self._key = key - self._name = name - self._entity_type = entity_type - self._icon = icon - self._unit = unit - self._device_class = device_class + self.entity_description = description - self._state = None - self._attributes = {} + self._attr_name = f"{keba.device_name} {description.name}" + self._attr_unique_id = f"{keba.device_id}_{entity_type}" - @property - def should_poll(self): - """Deactivate polling. Data updated by KebaHandler.""" - return False - - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return f"{self._keba.device_id}_{self._entity_type}" - - @property - def name(self): - """Return the name of the device.""" - return f"{self._keba.device_name} {self._name}" - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Get the unit of measurement.""" - return self._unit + self._attributes: dict[str, str] = {} @property def extra_state_attributes(self): @@ -120,9 +110,9 @@ class KebaSensor(SensorEntity): async def async_update(self): """Get latest cached states from the device.""" - self._state = self._keba.get_value(self._key) + self._attr_native_value = self._keba.get_value(self.entity_description.key) - if self._key == "P": + if self.entity_description.key == "P": self._attributes["power_factor"] = self._keba.get_value("PF") self._attributes["voltage_u1"] = str(self._keba.get_value("U1")) self._attributes["voltage_u2"] = str(self._keba.get_value("U2")) @@ -130,7 +120,7 @@ class KebaSensor(SensorEntity): self._attributes["current_i1"] = str(self._keba.get_value("I1")) self._attributes["current_i2"] = str(self._keba.get_value("I2")) self._attributes["current_i3"] = str(self._keba.get_value("I3")) - elif self._key == "Curr user": + elif self.entity_description.key == "Curr user": self._attributes["max_current_hardware"] = self._keba.get_value("Curr HW") def update_callback(self): diff --git a/homeassistant/components/kira/sensor.py b/homeassistant/components/kira/sensor.py index a6b1b9ada22..b28aac740f1 100644 --- a/homeassistant/components/kira/sensor.py +++ b/homeassistant/components/kira/sensor.py @@ -50,7 +50,7 @@ class KiraReceiver(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the state of the receiver.""" return self._state diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py index 8b34d423724..3bdb3074851 100644 --- a/homeassistant/components/kmtronic/const.py +++ b/homeassistant/components/kmtronic/const.py @@ -8,7 +8,5 @@ DATA_HUB = "hub" DATA_COORDINATOR = "coordinator" MANUFACTURER = "KMtronic" -ATTR_MANUFACTURER = "manufacturer" -ATTR_IDENTIFIERS = "identifiers" UPDATE_LISTENER = "update_listener" diff --git a/homeassistant/components/kmtronic/translations/zh-Hans.json b/homeassistant/components/kmtronic/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/kmtronic/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 803ded55441..91342cca839 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, - HVAC_MODE_HEAT, HVAC_MODE_OFF, PRESET_AWAY, SUPPORT_PRESET_MODE, @@ -187,6 +186,7 @@ class KNXClimate(KnxEntity, ClimateEntity): f"{self._device.target_temperature.group_address}_" f"{self._device._setpoint_shift.group_address}" ) + self.default_hvac_mode: str = config[ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE] async def async_update(self) -> None: """Request a state update from KNX bus.""" @@ -231,10 +231,9 @@ class KNXClimate(KnxEntity, ClimateEntity): return HVAC_MODE_OFF if self._device.mode is not None and self._device.mode.supports_controller_mode: return CONTROLLER_MODES.get( - self._device.mode.controller_mode.value, HVAC_MODE_HEAT + self._device.mode.controller_mode.value, self.default_hvac_mode ) - # default to "heat" - return HVAC_MODE_HEAT + return self.default_hvac_mode @property def hvac_modes(self) -> list[str]: @@ -248,12 +247,11 @@ class KNXClimate(KnxEntity, ClimateEntity): if self._device.supports_on_off: if not ha_controller_modes: - ha_controller_modes.append(HVAC_MODE_HEAT) + ha_controller_modes.append(self.default_hvac_mode) ha_controller_modes.append(HVAC_MODE_OFF) hvac_modes = list(set(filter(None, ha_controller_modes))) - # default to ["heat"] - return hvac_modes if hvac_modes else [HVAC_MODE_HEAT] + return hvac_modes if hvac_modes else [self.default_hvac_mode] @property def hvac_action(self) -> str | None: diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 11b2504d129..65ff6b3b8fa 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -16,7 +16,9 @@ from xknx.telegram.address import IndividualAddress, parse_device_group_address from homeassistant.components.binary_sensor import ( DEVICE_CLASSES as BINARY_SENSOR_DEVICE_CLASSES, ) +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.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_ID, @@ -286,6 +288,7 @@ class ClimateSchema(KNXPlatformSchema): CONF_OPERATION_MODE_STANDBY_ADDRESS = "operation_mode_standby_address" CONF_OPERATION_MODES = "operation_modes" CONF_CONTROLLER_MODES = "controller_modes" + CONF_DEFAULT_CONTROLLER_MODE = "default_controller_mode" CONF_ON_OFF_ADDRESS = "on_off_address" CONF_ON_OFF_STATE_ADDRESS = "on_off_state_address" CONF_ON_OFF_INVERT = "on_off_invert" @@ -361,6 +364,9 @@ class ClimateSchema(KNXPlatformSchema): vol.Optional(CONF_CONTROLLER_MODES): vol.All( cv.ensure_list, [vol.In(CONTROLLER_MODES)] ), + vol.Optional( + CONF_DEFAULT_CONTROLLER_MODE, default=HVAC_MODE_HEAT + ): vol.In(HVAC_MODES), vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), } @@ -724,6 +730,7 @@ 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" @@ -732,6 +739,7 @@ class SensorSchema(KNXPlatformSchema): vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, vol.Required(CONF_TYPE): sensor_type_validator, vol.Required(CONF_STATE_ADDRESS): ga_list_validator, } diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 07f74c04e4f..90e0203a8be 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -97,7 +97,5 @@ class KNXSelect(KnxEntity, SelectEntity, RestoreEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - payload = self._option_payloads.get(option) - if payload is None: - raise ValueError(f"Invalid option for {self.entity_id}: {option}") + payload = self._option_payloads[option] await self._device.set(payload) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index e095b2aee47..933ba7bf30d 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -62,10 +62,11 @@ 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_unit_of_measurement = self._device.unit_of_measurement() + self._attr_native_unit_of_measurement = self._device.unit_of_measurement() + self._attr_state_class = config.get(SensorSchema.CONF_STATE_CLASS) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self._device.resolve_state() diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index 584e465b3a6..ac474413b54 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Kodi.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -29,7 +31,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Kodi devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] diff --git a/homeassistant/components/kodi/translations/zh-Hans.json b/homeassistant/components/kodi/translations/zh-Hans.json index 6fe91b6e995..12915ccdb9b 100644 --- a/homeassistant/components/kodi/translations/zh-Hans.json +++ b/homeassistant/components/kodi/translations/zh-Hans.json @@ -1,11 +1,50 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u65e0\u6548\u9a8c\u8bc1", + "no_uuid": "Kodi \u5b9e\u4f8b\u6ca1\u6709\u552f\u4e00\u7684 ID\u3002\u8fd9\u5f88\u53ef\u80fd\u662f\u7531\u4e8e\u65e7\u7684 Kodi \u7248\u672c\uff0817.x \u6216\u66f4\u4f4e\u7248\u672c\uff09\u9020\u6210\u3002\u60a8\u53ef\u4ee5\u624b\u52a8\u914d\u7f6e\u96c6\u6210\u6216\u5347\u7ea7\u5230\u66f4\u65b0\u7684 Kodi \u7248\u672c\u3002", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "flow_title": "{name}", "step": { "credentials": { "data": { + "password": "\u5bc6\u7801", "username": "\u7528\u6237\u540d" - } + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u7684 Kodi \u7528\u6237\u540d\u548c\u5bc6\u7801\u3002\u8fd9\u4e9b\u53ef\u4ee5\u5728\u201c\u7cfb\u7edf/\u8bbe\u7f6e/\u7f51\u7edc/\u670d\u52a1\u201d\u4e2d\u627e\u5230\u3002" + }, + "discovery_confirm": { + "description": "\u60a8\u662f\u5426\u60f3\u8981\u5c06 Kodi (`{name}`) \u6dfb\u52a0\u5230 Home Assistant?", + "title": "\u5df2\u53d1\u73b0 Kodi" + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u9a8c\u8bc1" + }, + "description": "Kodi \u8fde\u63a5\u4fe1\u606f\u3002\n\u8bf7\u786e\u4fdd\u5728\u8bbe\u7f6e\uff1a\u201c\u7cfb\u7edf/\u8bbe\u7f6e/\u7f51\u7edc/\u670d\u52a1\u201d\u4e2d\u542f\u7528\u201c\u5141\u8bb8\u901a\u8fc7 HTTP \u63a7\u5236 Kodi\u201d\u3002" + }, + "ws_port": { + "data": { + "ws_port": "\u7aef\u53e3" + }, + "description": "WebSocket \u7aef\u53e3(\u5728 Kodi \u4e2d\u6709\u65f6\u79f0\u4e3a TCP \u7aef\u53e3)\u3002\u4e3a\u901a\u8fc7 WebSocket \u8fdb\u884c\u8fde\u63a5\uff0c\u60a8\u9700\u8981\u5728\"\u7cfb\u7edf/\u8bbe\u7f6e/\u7f51\u7edc/\u670d\u52a1\"\u4e2d\u542f\u7528\u201c\u5141\u8bb8\u7a0b\u5e8f...\u63a7\u5236 Kodi\u201d\u3002\u5982\u679c\u672a\u542f\u7528 WebSocket\uff0c\u8bf7\u79fb\u9664\u7aef\u53e3\u5e76\u7559\u7a7a\u3002" } } + }, + "device_automation": { + "trigger_type": { + "turn_off": "[entity_name} \u88ab\u8981\u6c42\u5173\u95ed", + "turn_on": "[entity_name} \u88ab\u8981\u6c42\u6253\u5f00" + } } } \ No newline at end of file diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 32d0f0e20c0..6785e2e7124 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -36,6 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .config_flow import ( # Loading the config flow file will register the flow CONF_DEFAULT_OPTIONS, @@ -220,7 +221,7 @@ YAML_CONFIGS = "yaml_configs" PLATFORMS = ["binary_sensor", "sensor", "switch"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Konnected platform.""" cfg = config.get(DOMAIN) if cfg is None: diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index 18975bdb467..a22b30f6862 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -104,12 +104,12 @@ class KonnectedSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/konnected/translations/en_GB.json b/homeassistant/components/konnected/translations/en_GB.json new file mode 100644 index 00000000000..f1597ad3a04 --- /dev/null +++ b/homeassistant/components/konnected/translations/en_GB.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "not_konn_panel": "Not a recognised Konnected.io device" + } + }, + "options": { + "abort": { + "not_konn_panel": "Not a recognised Konnected.io device" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/translations/he.json b/homeassistant/components/konnected/translations/he.json index 0a436bc2d3c..5bfc5453409 100644 --- a/homeassistant/components/konnected/translations/he.json +++ b/homeassistant/components/konnected/translations/he.json @@ -22,12 +22,6 @@ } }, "options": { - "error": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "step": { "options_binary": { "data": { diff --git a/homeassistant/components/konnected/translations/hu.json b/homeassistant/components/konnected/translations/hu.json index 507e5d258f2..1ad58223b88 100644 --- a/homeassistant/components/konnected/translations/hu.json +++ b/homeassistant/components/konnected/translations/hu.json @@ -3,38 +3,107 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "not_konn_panel": "Nem felismert Konnected.io eszk\u00f6z", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "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.", + "title": "Konnected eszk\u00f6z k\u00e9sz" + }, + "import_confirm": { + "description": "A konfigur\u00e1ci\u00f3s.yaml f\u00e1jlban felfedezt\u00fcnk egy Konnected Alarm Panel-t {id} Ez a folyamat lehet\u0151v\u00e9 teszi, hogy import\u00e1lja azt egy konfigur\u00e1ci\u00f3s bejegyz\u00e9sbe.", + "title": "Konnected eszk\u00f6z import\u00e1l\u00e1sa" + }, "user": { "data": { "host": "IP c\u00edm", "port": "Port" - } + }, + "description": "K\u00e9rj\u00fck, adja meg a Konnected Panel gazdag\u00e9p\u00e9nek adatait." } } }, "options": { + "abort": { + "not_konn_panel": "Nem felismert Konnected.io eszk\u00f6z" + }, + "error": { + "bad_host": "\u00c9rv\u00e9nytelen fel\u00fclb\u00edr\u00e1l\u00e1si API host URL", + "one": "\u00dcres", + "other": "\u00dcres" + }, "step": { "options_binary": { "data": { - "name": "N\u00e9v (nem k\u00f6telez\u0151)" - } + "inverse": "Invert\u00e1lja a nyitott/z\u00e1rt \u00e1llapotot", + "name": "N\u00e9v (nem k\u00f6telez\u0151)", + "type": "Bin\u00e1ris \u00e9rz\u00e9kel\u0151 t\u00edpusa" + }, + "description": "{zone} opci\u00f3k", + "title": "Bin\u00e1ris \u00e9rz\u00e9kel\u0151 konfigur\u00e1l\u00e1sa" }, "options_digital": { "data": { "name": "N\u00e9v (nem k\u00f6telez\u0151)", "poll_interval": "Lek\u00e9rdez\u00e9si id\u0151k\u00f6z (perc) (opcion\u00e1lis)", "type": "\u00c9rz\u00e9kel\u0151 t\u00edpusa" - } + }, + "description": "{zone} opci\u00f3k", + "title": "Digit\u00e1lis \u00e9rz\u00e9kel\u0151 konfigur\u00e1l\u00e1sa" + }, + "options_io": { + "data": { + "1": "1. z\u00f3na", + "2": "2. z\u00f3na", + "3": "3. z\u00f3na", + "4": "4. z\u00f3na", + "5": "5. z\u00f3na", + "6": "6. z\u00f3na", + "7": "7. z\u00f3na", + "out": "KI" + }, + "description": "{model} felfedez\u00e9se {host}-n\u00e1l. V\u00e1lassza ki az al\u00e1bbi I/O alapkonfigur\u00e1ci\u00f3j\u00e1t - az I/O-t\u00f3l f\u00fcgg\u0151en lehet\u0151v\u00e9 teheti bin\u00e1ris \u00e9rz\u00e9kel\u0151k (nyitott/k\u00f6zeli \u00e9rintkez\u0151k), digit\u00e1lis \u00e9rz\u00e9kel\u0151k (dht \u00e9s ds18b20) vagy kapcsolhat\u00f3 kimenetek sz\u00e1m\u00e1ra. A r\u00e9szletes be\u00e1ll\u00edt\u00e1sokat a k\u00f6vetkez\u0151 l\u00e9p\u00e9sekben konfigur\u00e1lhatja.", + "title": "I/O konfigur\u00e1l\u00e1sa" + }, + "options_io_ext": { + "data": { + "10": "10. z\u00f3na", + "11": "11. z\u00f3na", + "12": "12. z\u00f3na", + "8": "8. z\u00f3na", + "9": "9. z\u00f3na", + "alarm1": "RIASZT\u00c1S1", + "alarm2_out2": "KI2/RIASZT\u00c1S2", + "out1": "KI1" + }, + "description": "V\u00e1lassza ki a fennmarad\u00f3 I/O konfigur\u00e1ci\u00f3j\u00e1t al\u00e1bb. A k\u00f6vetkez\u0151 l\u00e9p\u00e9sekben konfigur\u00e1lhatja a r\u00e9szletes be\u00e1ll\u00edt\u00e1sokat.", + "title": "B\u0151v\u00edtett I/O konfigur\u00e1l\u00e1sa" + }, + "options_misc": { + "data": { + "api_host": "API host URL fel\u00fclb\u00edr\u00e1l\u00e1sa (opcion\u00e1lis)", + "blink": "A panel LED villog\u00e1sa \u00e1llapotv\u00e1ltoz\u00e1skor", + "discovery": "V\u00e1laszoljon a h\u00e1l\u00f3zaton \u00e9rkez\u0151 felder\u00edt\u00e9si k\u00e9r\u00e9sekre", + "override_api_host": "Az alap\u00e9rtelmezett Home Assistant API gazdag\u00e9p-URL fel\u00fcl\u00edr\u00e1sa" + }, + "description": "K\u00e9rj\u00fck, v\u00e1lassza ki a k\u00edv\u00e1nt viselked\u00e9st a panelhez", + "title": "Egy\u00e9b be\u00e1ll\u00edt\u00e1sa" }, "options_switch": { "data": { - "name": "N\u00e9v (nem k\u00f6telez\u0151)" - } + "activation": "Kimenet bekapcsolt \u00e1llapotban", + "momentary": "Impulzus id\u0151tartama (ms) (opcion\u00e1lis)", + "more_states": "Tov\u00e1bbi \u00e1llapotok konfigur\u00e1l\u00e1sa ehhez a z\u00f3n\u00e1hoz", + "name": "N\u00e9v (nem k\u00f6telez\u0151)", + "pause": "Sz\u00fcnet impulzusok k\u00f6z\u00f6tt (ms) (opcion\u00e1lis)", + "repeat": "Ism\u00e9tl\u00e9si id\u0151k (-1 = v\u00e9gtelen) (opcion\u00e1lis)" + }, + "description": "{zone} opci\u00f3k: \u00e1llapot {state}", + "title": "Kapcsolhat\u00f3 kimenet konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index 5c223f4f5d6..9f902da7d2f 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,5 +1,10 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -40,6 +45,7 @@ SENSOR_PROCESS_DATA = [ ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, ATTR_ENABLED_DEFAULT: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, "format_round", ), @@ -51,6 +57,7 @@ SENSOR_PROCESS_DATA = [ ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, ATTR_ENABLED_DEFAULT: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, "format_round", ), @@ -65,28 +72,44 @@ SENSOR_PROCESS_DATA = [ "devices:local", "HomeGrid_P", "Home Power from Grid", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local", "HomeOwn_P", "Home Power from Own", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local", "HomePv_P", "Home Power from PV", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local", "Home_P", "Home Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -97,6 +120,7 @@ SENSOR_PROCESS_DATA = [ ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, ATTR_ENABLED_DEFAULT: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, "format_round", ), @@ -104,28 +128,44 @@ SENSOR_PROCESS_DATA = [ "devices:local:pv1", "P", "DC1 Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local:pv2", "P", "DC2 Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local:pv3", "P", "DC3 Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( "devices:local", "PV2Bat_P", "PV to Battery Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -139,14 +179,18 @@ SENSOR_PROCESS_DATA = [ "devices:local:battery", "Cycles", "Battery Cycles", - {ATTR_ICON: "mdi:recycle"}, + {ATTR_ICON: "mdi:recycle", ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT}, "format_round", ), ( "devices:local:battery", "P", "Battery Power", - {ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER}, + { + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -174,7 +218,11 @@ SENSOR_PROCESS_DATA = [ "scb:statistic:EnergyFlow", "Statistic:Autarky:Total", "Autarky Total", - {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:chart-donut"}, + { + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chart-donut", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -202,7 +250,11 @@ SENSOR_PROCESS_DATA = [ "scb:statistic:EnergyFlow", "Statistic:OwnConsumptionRate:Total", "Own Consumption Rate Total", - {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:chart-donut"}, + { + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chart-donut", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, "format_round", ), ( @@ -249,6 +301,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -289,6 +342,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -329,6 +383,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -369,6 +424,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -409,6 +465,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -449,6 +506,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -489,6 +547,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), @@ -530,6 +589,7 @@ SENSOR_PROCESS_DATA = [ { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "format_energy", ), diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 717dfacbfdf..19ac4db0f90 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from typing import Any, Callable -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -165,7 +165,7 @@ class PlenticoreDataSensor(CoordinatorEntity, SensorEntity): return f"{self.platform_name} {self._sensor_name}" @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of this Sensor Entity or None.""" return self._sensor_data.get(ATTR_UNIT_OF_MEASUREMENT) @@ -179,13 +179,18 @@ class PlenticoreDataSensor(CoordinatorEntity, SensorEntity): """Return the class of this device, from component DEVICE_CLASSES.""" return self._sensor_data.get(ATTR_DEVICE_CLASS) + @property + def state_class(self) -> str | None: + """Return the class of the state of this device, from component STATE_CLASSES.""" + return self._sensor_data.get(ATTR_STATE_CLASS) + @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" return self._sensor_data.get(ATTR_ENABLED_DEFAULT, False) @property - def state(self) -> Any | None: + def native_value(self) -> Any | None: """Return the state of the sensor.""" if self.coordinator.data is None: # None is translated to STATE_UNKNOWN diff --git a/homeassistant/components/kostal_plenticore/translations/zh-Hans.json b/homeassistant/components/kostal_plenticore/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/kostal_plenticore/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index bc0a0a21845..1b9f8ca13cc 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -124,7 +124,7 @@ class KrakenSensor(CoordinatorEntity, SensorEntity): return self._name.lower() @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" return self._state @@ -229,7 +229,7 @@ class KrakenSensor(CoordinatorEntity, SensorEntity): return "mdi:cash" @property - def unit_of_measurement(self) -> str | None: + 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 diff --git a/homeassistant/components/kraken/translations/es-419.json b/homeassistant/components/kraken/translations/es-419.json new file mode 100644 index 00000000000..106ff98de0d --- /dev/null +++ b/homeassistant/components/kraken/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "one": "", + "other": "Otros" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/es.json b/homeassistant/components/kraken/translations/es.json index afcf3f92d45..1befa14a52b 100644 --- a/homeassistant/components/kraken/translations/es.json +++ b/homeassistant/components/kraken/translations/es.json @@ -1,4 +1,14 @@ { + "config": { + "step": { + "user": { + "data": { + "one": "", + "other": "Otros" + } + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/kraken/translations/he.json b/homeassistant/components/kraken/translations/he.json index 2be0837c966..460ab83938c 100644 --- a/homeassistant/components/kraken/translations/he.json +++ b/homeassistant/components/kraken/translations/he.json @@ -3,20 +3,8 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, - "error": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "step": { "user": { - "data": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" } } diff --git a/homeassistant/components/kraken/translations/nl.json b/homeassistant/components/kraken/translations/nl.json index 7de89d6b2dc..25fe63bebd5 100644 --- a/homeassistant/components/kraken/translations/nl.json +++ b/homeassistant/components/kraken/translations/nl.json @@ -9,10 +9,6 @@ }, "step": { "user": { - "data": { - "one": "Leeg", - "other": "Leeg" - }, "description": "Wil je beginnen met instellen?" } } diff --git a/homeassistant/components/kwb/sensor.py b/homeassistant/components/kwb/sensor.py index 1b56803fae6..c6d2794a06d 100644 --- a/homeassistant/components/kwb/sensor.py +++ b/homeassistant/components/kwb/sensor.py @@ -94,13 +94,13 @@ class KWBSensor(SensorEntity): return self._sensor.available @property - def state(self): + def native_value(self): """Return the state of value.""" if self._sensor.value is not None and self._sensor.available: return self._sensor.value return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._sensor.unit_of_measurement diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 2f93196a4bb..99aa39ce7cd 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -176,10 +176,10 @@ class LaCrosseTemperature(LaCrosseSensor): """Implementation of a Lacrosse temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._temperature @@ -187,11 +187,11 @@ class LaCrosseTemperature(LaCrosseSensor): class LaCrosseHumidity(LaCrosseSensor): """Implementation of a Lacrosse humidity sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_icon = "mdi:water-percent" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._humidity @@ -200,7 +200,7 @@ class LaCrosseBattery(LaCrosseSensor): """Implementation of a Lacrosse battery sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._low_battery is None: return None diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 128450826d6..66f05c5d34d 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -77,7 +77,7 @@ class LastfmSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 831e44dca8f..18a947f7757 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -42,48 +42,28 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class LaunchLibrarySensor(SensorEntity): """Representation of a launch_library Sensor.""" - def __init__(self, launches: PyLaunches, name: str) -> None: + _attr_icon = "mdi:rocket" + + def __init__(self, api: PyLaunches, name: str) -> None: """Initialize the sensor.""" - self.launches = launches - self.next_launch = None - self._name = name + self.api = api + self._attr_name = name async def async_update(self) -> None: """Get the latest data.""" try: - launches = await self.launches.upcoming_launches() + launches = await self.api.upcoming_launches() except PyLaunchesException as exception: _LOGGER.error("Error getting data, %s", exception) + self._attr_available = False else: - if launches: - self.next_launch = launches[0] - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def state(self) -> str | None: - """Return the state of the sensor.""" - if self.next_launch: - return self.next_launch.name - return None - - @property - def icon(self) -> str: - """Return the icon of the sensor.""" - return "mdi:rocket" - - @property - def extra_state_attributes(self) -> dict | None: - """Return attributes for the sensor.""" - if self.next_launch: - return { - ATTR_LAUNCH_TIME: self.next_launch.net, - ATTR_AGENCY: self.next_launch.launch_service_provider.name, - ATTR_AGENCY_COUNTRY_CODE: self.next_launch.pad.location.country_code, - ATTR_STREAM: self.next_launch.webcast_live, - ATTR_ATTRIBUTION: ATTRIBUTION, - } - return None + if next_launch := next((launch for launch in launches), None): + self._attr_available = True + self._attr_native_value = next_launch.name + self._attr_extra_state_attributes = { + ATTR_LAUNCH_TIME: next_launch.net, + ATTR_AGENCY: next_launch.launch_service_provider.name, + ATTR_AGENCY_COUNTRY_CODE: next_launch.pad.location.country_code, + ATTR_STREAM: next_launch.webcast_live, + ATTR_ATTRIBUTION: ATTRIBUTION, + } diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 53026d0294c..5aaede430c5 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -79,9 +79,9 @@ def get_device_connection( def get_resource(domain_name: str, domain_data: ConfigType) -> str: """Return the resource for the specified domain_data.""" - if domain_name in ["switch", "light"]: + if domain_name in ("switch", "light"): return cast(str, domain_data["output"]) - if domain_name in ["binary_sensor", "sensor"]: + if domain_name in ("binary_sensor", "sensor"): return cast(str, domain_data["source"]) if domain_name == "cover": return cast(str, domain_data["motor"]) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index fdd6ee51872..965e9626f66 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -93,12 +93,12 @@ class LcnVariableSensor(LcnEntity, SensorEntity): await self.device_connection.cancel_status_request_handler(self.variable) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self._value @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return cast(str, self.unit.value) @@ -145,7 +145,7 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity): await self.device_connection.cancel_status_request_handler(self.source) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self._value diff --git a/homeassistant/components/life360/translations/hu.json b/homeassistant/components/life360/translations/hu.json index 603efee6d9d..5dbd2898971 100644 --- a/homeassistant/components/life360/translations/hu.json +++ b/homeassistant/components/life360/translations/hu.json @@ -18,7 +18,9 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A speci\u00e1lis be\u00e1ll\u00edt\u00e1sok megad\u00e1s\u00e1hoz l\u00e1sd a [Life360 dokument\u00e1ci\u00f3]({docs_url}) c\u00edm\u0171 r\u00e9szt.\n \u00c9rdemes ezt megtenni a fi\u00f3kok hozz\u00e1ad\u00e1sa el\u0151tt.", + "title": "Life360 fi\u00f3kadatok" } } } diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 30c0ffbe850..a4412d042a8 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -470,7 +470,7 @@ class LIFXLight(LightEntity): model = product_map.get(self.bulb.product) or self.bulb.product if model is not None: - info["model"] = model + info["model"] = str(model) return info diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 9e1a4fc2689..847c75b4fa5 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.6.9", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.6.10", "aiolifx_effects==0.2.2"], "homekit": { "models": ["LIFX"] }, diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 51f387a2cdb..4a0025126c8 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -445,7 +445,11 @@ async def async_setup(hass, config): # noqa: C901 ) # If both white and brightness are specified, override white - if ATTR_WHITE in params and COLOR_MODE_WHITE in supported_color_modes: + if ( + supported_color_modes + and ATTR_WHITE in params + and COLOR_MODE_WHITE in supported_color_modes + ): params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) # Remove deprecated white value if the light supports color mode @@ -529,7 +533,7 @@ class Profile: transition: int | None = None hs_color: tuple[float, float] | None = dataclasses.field(init=False) - SCHEMA = vol.Schema( # pylint: disable=invalid-name + SCHEMA = vol.Schema( vol.Any( vol.ExactSequence( ( @@ -829,6 +833,13 @@ class LightEntity(ToggleEntity): data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_RGBWW_COLOR] = tuple(int(x) for x in rgbww_color[0:5]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif color_mode == COLOR_MODE_COLOR_TEMP and self.color_temp: + hs_color = color_util.color_temperature_to_hs( + color_util.color_temperature_mired_to_kelvin(self.color_temp) + ) + data[ATTR_HS_COLOR] = (round(hs_color[0], 3), round(hs_color[1], 3)) + data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) + data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) return data @final @@ -863,7 +874,7 @@ class LightEntity(ToggleEntity): if color_mode == COLOR_MODE_COLOR_TEMP: data[ATTR_COLOR_TEMP] = self.color_temp - if color_mode in COLOR_MODES_COLOR: + if color_mode in COLOR_MODES_COLOR or color_mode == COLOR_MODE_COLOR_TEMP: data.update(self._light_internal_convert_color(color_mode)) if supported_features & SUPPORT_COLOR_TEMP and not self.supported_color_modes: diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 2180bdd3094..a933d04066e 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -32,6 +32,8 @@ from . import ( get_supported_color_modes, ) +# mypy: disallow-any-generics + TYPE_BRIGHTNESS_INCREASE = "brightness_increase" TYPE_BRIGHTNESS_DECREASE = "brightness_decrease" TYPE_FLASH = "flash" @@ -86,7 +88,9 @@ async def async_call_action_from_config( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions.""" actions = await toggle_entity.async_get_actions(hass, device_id, DOMAIN) @@ -119,7 +123,9 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: return actions -async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List action capabilities.""" if config[CONF_TYPE] != toggle_entity.CONF_TURN_ON: return {} diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py index 7396ddeea31..e5ff8a83ba3 100644 --- a/homeassistant/components/light/device_condition.py +++ b/homeassistant/components/light/device_condition.py @@ -11,6 +11,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN +# mypy: disallow-any-generics + CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( {vol.Required(CONF_DOMAIN): DOMAIN} ) @@ -33,6 +35,8 @@ async def async_get_conditions( return await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return await toggle_entity.async_get_condition_capabilities(hass, config) diff --git a/homeassistant/components/light/device_trigger.py b/homeassistant/components/light/device_trigger.py index e1b14124831..6cb6e8a34c1 100644 --- a/homeassistant/components/light/device_trigger.py +++ b/homeassistant/components/light/device_trigger.py @@ -1,6 +1,8 @@ """Provides device trigger for lights.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,11 +30,15 @@ async def async_attach_trigger( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/light/translations/he.json b/homeassistant/components/light/translations/he.json index a61237ba51e..934212effb2 100644 --- a/homeassistant/components/light/translations/he.json +++ b/homeassistant/components/light/translations/he.json @@ -23,5 +23,5 @@ "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, - "title": "\u05d0\u05d5\u05b9\u05e8" + "title": "\u05ea\u05d0\u05d5\u05e8\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/light/translations/hu.json b/homeassistant/components/light/translations/hu.json index ad215a5ba4c..1ac835fd1af 100644 --- a/homeassistant/components/light/translations/hu.json +++ b/homeassistant/components/light/translations/hu.json @@ -3,6 +3,7 @@ "action_type": { "brightness_decrease": "{entity_name} f\u00e9nyerej\u00e9nek cs\u00f6kkent\u00e9se", "brightness_increase": "{entity_name} f\u00e9nyerej\u00e9nek n\u00f6vel\u00e9se", + "flash": "Vaku {entity_name}", "toggle": "{entity_name} fel/lekapcsol\u00e1sa", "turn_off": "{entity_name} lekapcsol\u00e1sa", "turn_on": "{entity_name} felkapcsol\u00e1sa" diff --git a/homeassistant/components/lightwave/sensor.py b/homeassistant/components/lightwave/sensor.py index b298b78c7f6..369256ce403 100644 --- a/homeassistant/components/lightwave/sensor.py +++ b/homeassistant/components/lightwave/sensor.py @@ -26,7 +26,7 @@ class LightwaveBattery(SensorEntity): """Lightwave TRV Battery.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, name, lwlink, serial): @@ -43,7 +43,7 @@ class LightwaveBattery(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index b7746392cee..18f1c81e368 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -90,12 +90,12 @@ class LinuxBatterySensor(SensorEntity): return DEVICE_CLASS_BATTERY @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._battery_stat.capacity @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return PERCENTAGE diff --git a/homeassistant/components/litejet/translations/es.json b/homeassistant/components/litejet/translations/es.json index 32d39e995e1..41875da9e69 100644 --- a/homeassistant/components/litejet/translations/es.json +++ b/homeassistant/components/litejet/translations/es.json @@ -15,5 +15,15 @@ "title": "Conectarse a LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Transici\u00f3n predeterminada (segundos)" + }, + "title": "Configurar LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/hu.json b/homeassistant/components/litejet/translations/hu.json index 3ee53c086bf..910d34cdc1a 100644 --- a/homeassistant/components/litejet/translations/hu.json +++ b/homeassistant/components/litejet/translations/hu.json @@ -15,5 +15,15 @@ "title": "Csatlakoz\u00e1s a LiteJet-hez" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Alap\u00e9rtelmezett \u00e1tmenet (m\u00e1sodperc)" + }, + "title": "A LiteJet konfigur\u00e1l\u00e1sa" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/no.json b/homeassistant/components/litejet/translations/no.json index d3206ca2897..f6e2379900d 100644 --- a/homeassistant/components/litejet/translations/no.json +++ b/homeassistant/components/litejet/translations/no.json @@ -15,5 +15,15 @@ "title": "Koble til LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Standard overgang (sekunder)" + }, + "title": "Konfigurer LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/zh-Hans.json b/homeassistant/components/litejet/translations/zh-Hans.json new file mode 100644 index 00000000000..133385be2d3 --- /dev/null +++ b/homeassistant/components/litejet/translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "options": { + "step": { + "init": { + "title": "\u914d\u7f6e LiteJet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index facf79a7bd7..543a15736fe 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,7 +3,7 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2021.8.0"], + "requirements": ["pylitterbot==2021.8.1"], "codeowners": ["@natekspencer"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 15ea68f8342..cbcb75c0b23 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -36,7 +36,7 @@ class LitterRobotPropertySensor(LitterRobotEntity, SensorEntity): self.sensor_attribute = sensor_attribute @property - def state(self) -> str: + def native_value(self) -> str: """Return the state.""" return getattr(self.robot, self.sensor_attribute) @@ -45,7 +45,7 @@ class LitterRobotWasteSensor(LitterRobotPropertySensor): """Litter-Robot waste sensor.""" @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return unit of measurement.""" return PERCENTAGE @@ -59,10 +59,10 @@ class LitterRobotSleepTimeSensor(LitterRobotPropertySensor): """Litter-Robot sleep time sensor.""" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state.""" if self.robot.sleep_mode_enabled: - return super().state.isoformat() + return super().native_value.isoformat() return None @property diff --git a/homeassistant/components/litterrobot/translations/zh-Hans.json b/homeassistant/components/litterrobot/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/litterrobot/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 86a075c1a14..6e665ccd1c2 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -1,4 +1,6 @@ """Camera that loads a picture from a local file.""" +from __future__ import annotations + import logging import mimetypes import os @@ -73,7 +75,9 @@ class LocalFile(Camera): if content is not None: self.content_type = content - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" try: with open(self._file_path, "rb") as file: @@ -84,6 +88,7 @@ class LocalFile(Camera): self._name, self._file_path, ) + return None def check_file_path_access(self, file_path): """Check that filepath given is readable.""" diff --git a/homeassistant/components/local_ip/sensor.py b/homeassistant/components/local_ip/sensor.py index 661ef88e641..bd1b3d54fac 100644 --- a/homeassistant/components/local_ip/sensor.py +++ b/homeassistant/components/local_ip/sensor.py @@ -33,6 +33,6 @@ class IPSensor(SensorEntity): async def async_update(self) -> None: """Fetch new state data for the sensor.""" - self._attr_state = await async_get_source_ip( + self._attr_native_value = await async_get_source_ip( self.hass, target_ip=PUBLIC_TARGET_IP ) diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index 6c0eb2a41d4..50c205d113a 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -30,7 +30,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Lock devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index d0829eb742b..74b55a1a89c 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -23,6 +23,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN +# mypy: disallow-any-generics + CONDITION_TYPES = { "is_locked", "is_unlocked", @@ -39,7 +41,9 @@ CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( ) -async def async_get_conditions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device conditions for Lock devices.""" registry = await entity_registry.async_get_registry(hass) conditions = [] diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 641030e9f23..393fd968437 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Lock.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -36,7 +38,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Lock devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -61,7 +65,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index 9e1a4803e11..d9060b10080 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -29,8 +29,8 @@ from .const import ( DEFAULT_CACHEDB, DOMAIN, LED_MODE_KEY, - LOGI_SENSORS, RECORDING_MODE_KEY, + SENSOR_TYPES, SIGNAL_LOGI_CIRCLE_RECONFIGURE, SIGNAL_LOGI_CIRCLE_RECORD, SIGNAL_LOGI_CIRCLE_SNAPSHOT, @@ -50,10 +50,12 @@ ATTR_DURATION = "duration" PLATFORMS = ["camera", "sensor"] +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] + SENSOR_SCHEMA = vol.Schema( { - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(LOGI_SENSORS)): vol.All( - cv.ensure_list, [vol.In(LOGI_SENSORS)] + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ) } ) diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 1afeb190c8b..30407f03ecf 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -1,4 +1,6 @@ """Support to the Logi Circle cameras.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -142,7 +144,9 @@ class LogiCam(Camera): return state - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image from the camera.""" return await self._camera.live_stream.download_jpeg() diff --git a/homeassistant/components/logi_circle/const.py b/homeassistant/components/logi_circle/const.py index 92967d2eb84..02e51993198 100644 --- a/homeassistant/components/logi_circle/const.py +++ b/homeassistant/components/logi_circle/const.py @@ -1,4 +1,7 @@ """Constants in Logi Circle component.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import PERCENTAGE DOMAIN = "logi_circle" @@ -12,15 +15,40 @@ DEFAULT_CACHEDB = ".logi_cache.pickle" LED_MODE_KEY = "LED" RECORDING_MODE_KEY = "RECORDING_MODE" -# Sensor types: Name, unit of measure, icon per sensor key. -LOGI_SENSORS = { - "battery_level": ["Battery", PERCENTAGE, "battery-50"], - "last_activity_time": ["Last Activity", None, "history"], - "recording": ["Recording Mode", None, "eye"], - "signal_strength_category": ["WiFi Signal Category", None, "wifi"], - "signal_strength_percentage": ["WiFi Signal Strength", PERCENTAGE, "wifi"], - "streaming": ["Streaming Mode", None, "camera"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="battery_level", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-50", + ), + SensorEntityDescription( + key="last_activity_time", + name="Last Activity", + icon="mdi:history", + ), + SensorEntityDescription( + key="recording", + name="Recording Mode", + icon="mdi:eye", + ), + SensorEntityDescription( + key="signal_strength_category", + name="WiFi Signal Category", + icon="mdi:wifi", + ), + SensorEntityDescription( + key="signal_strength_percentage", + name="WiFi Signal Strength", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:wifi", + ), + SensorEntityDescription( + key="streaming", + name="Streaming Mode", + icon="mdi:camera", + ), +) SIGNAL_LOGI_CIRCLE_RECONFIGURE = "logi_circle_reconfigure" SIGNAL_LOGI_CIRCLE_SNAPSHOT = "logi_circle_snapshot" diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index 29cd6e28e1c..50671152587 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -1,7 +1,10 @@ """Support for Logi Circle sensors.""" -import logging +from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +import logging +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, @@ -13,12 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util.dt import as_local -from .const import ( - ATTRIBUTION, - DEVICE_BRAND, - DOMAIN as LOGI_CIRCLE_DOMAIN, - LOGI_SENSORS as SENSOR_TYPES, -) +from .const import ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN, SENSOR_TYPES _LOGGER = logging.getLogger(__name__) @@ -33,44 +31,30 @@ async def async_setup_entry(hass, entry, async_add_entities): devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras time_zone = str(hass.config.time_zone) - sensors = [] - for sensor_type in entry.data.get(CONF_SENSORS).get(CONF_MONITORED_CONDITIONS): - for device in devices: - if device.supports_feature(sensor_type): - sensors.append(LogiSensor(device, time_zone, sensor_type)) + monitored_conditions = entry.data.get(CONF_SENSORS).get(CONF_MONITORED_CONDITIONS) + entities = [ + LogiSensor(device, time_zone, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + for device in devices + if device.supports_feature(description.key) + ] - async_add_entities(sensors, True) + async_add_entities(entities, True) class LogiSensor(SensorEntity): """A sensor implementation for a Logi Circle camera.""" - def __init__(self, camera, time_zone, sensor_type): + def __init__(self, camera, time_zone, description: SensorEntityDescription): """Initialize a sensor for Logi Circle camera.""" - self._sensor_type = sensor_type + self.entity_description = description self._camera = camera - self._id = f"{self._camera.mac_address}-{self._sensor_type}" - self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" - self._name = f"{self._camera.name} {SENSOR_TYPES.get(self._sensor_type)[0]}" - self._activity = {} - self._state = None + self._attr_unique_id = f"{camera.mac_address}-{description.key}" + self._attr_name = f"{camera.name} {description.name}" + self._activity: dict[Any, Any] = {} self._tz = time_zone - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - @property def device_info(self): """Return information about the device.""" @@ -93,7 +77,7 @@ class LogiSensor(SensorEntity): "microphone_gain": self._camera.microphone_gain, } - if self._sensor_type == "battery_level": + if self.entity_description.key == "battery_level": state[ATTR_BATTERY_CHARGING] = self._camera.charging return state @@ -101,37 +85,36 @@ class LogiSensor(SensorEntity): @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery_level" and self._state is not None: + sensor_type = self.entity_description.key + if sensor_type == "battery_level" and self._attr_native_value is not None: return icon_for_battery_level( - battery_level=int(self._state), charging=False + battery_level=int(self._attr_native_value), charging=False ) - if self._sensor_type == "recording_mode" and self._state is not None: - return "mdi:eye" if self._state == STATE_ON else "mdi:eye-off" - if self._sensor_type == "streaming_mode" and self._state is not None: - return "mdi:camera" if self._state == STATE_ON else "mdi:camera-off" - return self._icon - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return SENSOR_TYPES.get(self._sensor_type)[1] + if sensor_type == "recording_mode" and self._attr_native_value is not None: + return "mdi:eye" if self._attr_native_value == STATE_ON else "mdi:eye-off" + if sensor_type == "streaming_mode" and self._attr_native_value is not None: + return ( + "mdi:camera" + if self._attr_native_value == STATE_ON + else "mdi:camera-off" + ) + return self.entity_description.icon async def async_update(self): """Get the latest data and updates the state.""" - _LOGGER.debug("Pulling data from %s sensor", self._name) + _LOGGER.debug("Pulling data from %s sensor", self.name) await self._camera.update() - if self._sensor_type == "last_activity_time": + if self.entity_description.key == "last_activity_time": last_activity = await self._camera.get_last_activity(force_refresh=True) if last_activity is not None: last_activity_time = as_local(last_activity.end_time_utc) - self._state = ( + self._attr_native_value = ( f"{last_activity_time.hour:0>2}:{last_activity_time.minute:0>2}" ) else: - state = getattr(self._camera, self._sensor_type, None) + state = getattr(self._camera, self.entity_description.key, None) if isinstance(state, bool): - self._state = STATE_ON if state is True else STATE_OFF + self._attr_native_value = STATE_ON if state is True else STATE_OFF else: - self._state = state - self._state = state + self._attr_native_value = state diff --git a/homeassistant/components/logi_circle/translations/en_GB.json b/homeassistant/components/logi_circle/translations/en_GB.json new file mode 100644 index 00000000000..e93e0075042 --- /dev/null +++ b/homeassistant/components/logi_circle/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/logi_circle/translations/hu.json b/homeassistant/components/logi_circle/translations/hu.json index 9c788350de4..73522a59519 100644 --- a/homeassistant/components/logi_circle/translations/hu.json +++ b/homeassistant/components/logi_circle/translations/hu.json @@ -2,6 +2,8 @@ "config": { "abort": { "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." }, "error": { @@ -10,10 +12,15 @@ "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})", + "title": "Hiteles\u00edt\u00e9s a LogiCircle seg\u00edts\u00e9g\u00e9vel" + }, "user": { "data": { "flow_impl": "Szolg\u00e1ltat\u00f3" }, + "description": "V\u00e1lassza ki, melyik hiteles\u00edt\u00e9si szolg\u00e1ltat\u00f3n kereszt\u00fcl szeretn\u00e9 hiteles\u00edteni a LogiCircle szolg\u00e1ltat\u00e1st.", "title": "Hiteles\u00edt\u00e9si Szolg\u00e1ltat\u00f3" } } diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 23eea5c00e0..23bc67f46bc 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -108,7 +108,7 @@ class AirSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index b7f2cb50cbf..eb962772fe5 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -67,7 +67,7 @@ class LondonTubeSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/loopenergy/sensor.py b/homeassistant/components/loopenergy/sensor.py index 78e55f22eb8..05d7f79ebfd 100644 --- a/homeassistant/components/loopenergy/sensor.py +++ b/homeassistant/components/loopenergy/sensor.py @@ -97,7 +97,7 @@ class LoopEnergySensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -107,7 +107,7 @@ class LoopEnergySensor(SensorEntity): return False @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index e16f1399c40..d8fe591a0ba 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -67,7 +67,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Lovelace commands.""" mode = config[DOMAIN][CONF_MODE] yaml_resources = config[DOMAIN].get(CONF_RESOURCES) diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index b27cc35ab26..b4bdd7f30b3 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -73,7 +73,7 @@ class LuftdatenSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the device.""" if self._data is not None: try: @@ -82,7 +82,7 @@ class LuftdatenSensor(SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/luftdaten/translations/lt.json b/homeassistant/components/luftdaten/translations/lt.json new file mode 100644 index 00000000000..3ab861ad9ee --- /dev/null +++ b/homeassistant/components/luftdaten/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "show_on_map": "Rodyti \u017eem\u0117lapyje" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index b38968c36b8..de8ff228bc4 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -128,6 +128,11 @@ class LutronDevice(Entity): """No polling needed.""" return False + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._controller.guid}_{self._lutron_device.uuid}" + class LutronButton: """Representation of a button on a Lutron keypad. @@ -140,6 +145,8 @@ class LutronButton: def __init__(self, hass, area_name, keypad, button): """Register callback for activity on the button.""" name = f"{keypad.name}: {button.name}" + if button.name == "Unknown Button": + name += f" {button.number}" self._hass = hass self._has_release_event = ( button.button_type is not None and "RaiseLower" in button.button_type @@ -150,7 +157,7 @@ class LutronButton: self._button_name = button.name self._button = button self._event = "lutron_event" - self._full_id = slugify(f"{area_name} {keypad.name}: {button.name}") + self._full_id = slugify(f"{area_name} {name}") button.subscribe(self.button_callback, None) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 7d9728a79a1..8a7f321e158 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for lutron caseta.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -225,7 +227,9 @@ async def async_validate_trigger_config(hass: HomeAssistant, config: ConfigType) return schema(config) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for lutron caseta devices.""" triggers = [] diff --git a/homeassistant/components/lutron_caseta/translations/hu.json b/homeassistant/components/lutron_caseta/translations/hu.json index 905fc05bf8e..0e8960530e3 100644 --- a/homeassistant/components/lutron_caseta/translations/hu.json +++ b/homeassistant/components/lutron_caseta/translations/hu.json @@ -10,6 +10,10 @@ }, "flow_title": "{name} ({host})", "step": { + "import_failed": { + "description": "Nem siker\u00fclt be\u00e1ll\u00edtani a bridge-t ({host}) a configuration.yaml f\u00e1jlb\u00f3l import\u00e1lva.", + "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.", "title": "P\u00e1ros\u00edtsd a h\u00edddal" diff --git a/homeassistant/components/lyft/sensor.py b/homeassistant/components/lyft/sensor.py index 39cfff38a1b..84e3744a0e2 100644 --- a/homeassistant/components/lyft/sensor.py +++ b/homeassistant/components/lyft/sensor.py @@ -103,12 +103,12 @@ class LyftSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index e958567940a..07c5bfeaf89 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers import ( device_registry as dr, ) from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -50,7 +51,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = ["climate", "sensor"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Honeywell Lyric component.""" hass.data[DOMAIN] = {} @@ -139,18 +140,12 @@ class LyricEntity(CoordinatorEntity): location: LyricLocation, device: LyricDevice, key: str, - name: str, - icon: str | None, ) -> None: """Initialize the Honeywell Lyric entity.""" super().__init__(coordinator) self._key = key - self._name = name - self._icon = icon self._location = location self._mac_id = device.macID - self._device_name = device.name - self._device_model = device.deviceModel self._update_thermostat = coordinator.data.update_thermostat @property @@ -158,16 +153,6 @@ class LyricEntity(CoordinatorEntity): """Return the unique ID for this entity.""" return self._key - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - @property def location(self) -> LyricLocation: """Get the Lyric Location.""" @@ -188,6 +173,6 @@ class LyricDeviceEntity(LyricEntity): return { "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, "manufacturer": "Honeywell", - "model": self._device_model, - "name": self._device_name, + "model": self.device.deviceModel, + "name": self.device.name, } diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index a13f0381499..940740bd397 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -8,7 +8,7 @@ from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation import voluptuous as vol -from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate import ClimateEntity, ClimateEntityDescription from homeassistant.components.climate.const import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -99,7 +99,14 @@ async def async_setup_entry( for device in location.devices: entities.append( LyricClimate( - coordinator, location, device, hass.config.units.temperature_unit + coordinator, + ClimateEntityDescription( + key=f"{device.macID}_thermostat", + name=device.name, + ), + location, + device, + hass.config.units.temperature_unit, ) ) @@ -117,9 +124,13 @@ async def async_setup_entry( class LyricClimate(LyricDeviceEntity, ClimateEntity): """Defines a Honeywell Lyric climate entity.""" + coordinator: DataUpdateCoordinator + entity_description: ClimateEntityDescription + def __init__( self, coordinator: DataUpdateCoordinator, + description: ClimateEntityDescription, location: LyricLocation, device: LyricDevice, temperature_unit: str, @@ -148,9 +159,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): location, device, f"{device.macID}_thermostat", - device.name, - None, ) + self.entity_description = description @property def supported_features(self) -> int: @@ -190,6 +200,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): """Return the temperature we try to reach.""" device = self.device if not device.hasDualSetpointStatus: + if self.hvac_mode == HVAC_MODE_COOL: + return device.changeableValues.coolSetpoint return device.changeableValues.heatSetpoint return None @@ -266,7 +278,14 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): temp = kwargs.get(ATTR_TEMPERATURE) _LOGGER.debug("Set temperature: %s", temp) try: - await self._update_thermostat(self.location, device, heatSetpoint=temp) + if self.hvac_mode == HVAC_MODE_COOL: + await self._update_thermostat( + self.location, device, coolSetpoint=temp + ) + else: + await self._update_thermostat( + self.location, device, heatSetpoint=temp + ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) await self.coordinator.async_refresh() diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index f4d4d4b999a..b5b0ffdeb3d 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -1,10 +1,16 @@ """Support for Honeywell Lyric sensor platform.""" +from dataclasses import dataclass from datetime import datetime, timedelta +from typing import Callable, cast from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation -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 ( DEVICE_CLASS_HUMIDITY, @@ -12,6 +18,7 @@ from homeassistant.const import ( DEVICE_CLASS_TIMESTAMP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -33,6 +40,22 @@ LYRIC_SETPOINT_STATUS_NAMES = { } +@dataclass +class LyricSensorEntityDescription(SensorEntityDescription): + """Class describing Honeywell Lyric sensor entities.""" + + value: Callable[[LyricDevice], StateType] = round + + +def get_datetime_from_future_time(time: str) -> datetime: + """Get datetime from future time provided.""" + time = dt_util.parse_time(time) + now = dt_util.utcnow() + if time <= now.time(): + now = now + timedelta(days=1) + return dt_util.as_utc(datetime.combine(now.date(), time)) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: @@ -41,212 +64,126 @@ async def async_setup_entry( entities = [] + def get_setpoint_status(status: str, time: str) -> str: + if status == PRESET_HOLD_UNTIL: + return f"Held until {time}" + return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) + for location in coordinator.data.locations: for device in location.devices: - cls_list = [] if device.indoorTemperature: - cls_list.append(LyricIndoorTemperatureSensor) - if device.outdoorTemperature: - cls_list.append(LyricOutdoorTemperatureSensor) - if device.displayedOutdoorHumidity: - cls_list.append(LyricOutdoorHumiditySensor) - if device.changeableValues: - if device.changeableValues.nextPeriodTime: - cls_list.append(LyricNextPeriodSensor) - if device.changeableValues.thermostatSetpointStatus: - cls_list.append(LyricSetpointStatusSensor) - for cls in cls_list: entities.append( - cls( + LyricSensor( coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_indoor_temperature", + name="Indoor Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=hass.config.units.temperature_unit, + value=lambda device: device.indoorTemperature, + ), location, device, - hass.config.units.temperature_unit, ) ) + if device.outdoorTemperature: + entities.append( + LyricSensor( + coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_outdoor_temperature", + name="Outdoor Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=hass.config.units.temperature_unit, + value=lambda device: device.outdoorTemperature, + ), + location, + device, + ) + ) + if device.displayedOutdoorHumidity: + entities.append( + LyricSensor( + coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_outdoor_humidity", + name="Outdoor Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement="%", + value=lambda device: device.displayedOutdoorHumidity, + ), + location, + device, + ) + ) + if device.changeableValues: + if device.changeableValues.nextPeriodTime: + entities.append( + LyricSensor( + coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_next_period_time", + name="Next Period Time", + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda device: get_datetime_from_future_time( + device.changeableValues.nextPeriodTime + ).isoformat(), + ), + location, + device, + ) + ) + if device.changeableValues.thermostatSetpointStatus: + entities.append( + LyricSensor( + coordinator, + LyricSensorEntityDescription( + key=f"{device.macID}_setpoint_status", + name="Setpoint Status", + icon="mdi:thermostat", + value=lambda device: get_setpoint_status( + device.changeableValues.thermostatSetpointStatus, + device.changeableValues.nextPeriodTime, + ), + ), + location, + device, + ) + ) async_add_entities(entities, True) class LyricSensor(LyricDeviceEntity, SensorEntity): - """Defines a Honeywell Lyric sensor.""" + """Define a Honeywell Lyric sensor.""" + + coordinator: DataUpdateCoordinator + entity_description: LyricSensorEntityDescription def __init__( self, coordinator: DataUpdateCoordinator, + description: LyricSensorEntityDescription, location: LyricLocation, device: LyricDevice, - key: str, - name: str, - icon: str, - device_class: str = None, - unit_of_measurement: str = None, ) -> None: - """Initialize Honeywell Lyric sensor.""" - self._device_class = device_class - self._unit_of_measurement = unit_of_measurement - - super().__init__(coordinator, location, device, key, name, icon) - - @property - def device_class(self) -> str: - """Return the device class of the sensor.""" - return self._device_class - - @property - def unit_of_measurement(self) -> str: - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - -class LyricIndoorTemperatureSensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - + """Initialize.""" super().__init__( coordinator, location, device, - f"{device.macID}_indoor_temperature", - "Indoor Temperature", - None, - DEVICE_CLASS_TEMPERATURE, - unit_of_measurement, + description.key, ) + self.entity_description = description @property - def state(self) -> str: - """Return the state of the sensor.""" - return self.device.indoorTemperature - - -class LyricOutdoorTemperatureSensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - - super().__init__( - coordinator, - location, - device, - f"{device.macID}_outdoor_temperature", - "Outdoor Temperature", - None, - DEVICE_CLASS_TEMPERATURE, - unit_of_measurement, - ) - - @property - def state(self) -> str: - """Return the state of the sensor.""" - return self.device.outdoorTemperature - - -class LyricOutdoorHumiditySensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - - super().__init__( - coordinator, - location, - device, - f"{device.macID}_outdoor_humidity", - "Outdoor Humidity", - None, - DEVICE_CLASS_HUMIDITY, - "%", - ) - - @property - def state(self) -> str: - """Return the state of the sensor.""" - return self.device.displayedOutdoorHumidity - - -class LyricNextPeriodSensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - - super().__init__( - coordinator, - location, - device, - f"{device.macID}_next_period_time", - "Next Period Time", - None, - DEVICE_CLASS_TIMESTAMP, - ) - - @property - def state(self) -> datetime: - """Return the state of the sensor.""" - device = self.device - time = dt_util.parse_time(device.changeableValues.nextPeriodTime) - now = dt_util.utcnow() - if time <= now.time(): - now = now + timedelta(days=1) - return dt_util.as_utc(datetime.combine(now.date(), time)) - - -class LyricSetpointStatusSensor(LyricSensor): - """Defines a Honeywell Lyric sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - location: LyricLocation, - device: LyricDevice, - unit_of_measurement: str = None, - ) -> None: - """Initialize Honeywell Lyric sensor.""" - - super().__init__( - coordinator, - location, - device, - f"{device.macID}_setpoint_status", - "Setpoint Status", - "mdi:thermostat", - None, - ) - - @property - def state(self) -> str: - """Return the state of the sensor.""" - device = self.device - if device.changeableValues.thermostatSetpointStatus == PRESET_HOLD_UNTIL: - return f"Held until {device.changeableValues.nextPeriodTime}" - return LYRIC_SETPOINT_STATUS_NAMES.get( - device.changeableValues.thermostatSetpointStatus, "Unknown" - ) + def native_value(self) -> StateType: + """Return the state.""" + device: LyricDevice = self.device + try: + return cast(StateType, self.entity_description.value(device)) + except TypeError: + return None diff --git a/homeassistant/components/lyric/translations/en_GB.json b/homeassistant/components/lyric/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/lyric/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py index 0dd27a60ae0..5979759f416 100644 --- a/homeassistant/components/magicseaweed/sensor.py +++ b/homeassistant/components/magicseaweed/sensor.py @@ -1,11 +1,17 @@ """Support for magicseaweed data from magicseaweed.com.""" +from __future__ import annotations + from datetime import timedelta import logging import magicseaweed 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_API_KEY, @@ -30,18 +36,30 @@ ICON = "mdi:waves" HOURS = ["12AM", "3AM", "6AM", "9AM", "12PM", "3PM", "6PM", "9PM"] -SENSOR_TYPES = { - "max_breaking_swell": ["Max"], - "min_breaking_swell": ["Min"], - "swell_forecast": ["Forecast"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="max_breaking_swell", + name="Max", + ), + SensorEntityDescription( + key="min_breaking_swell", + name="Min", + ), + SensorEntityDescription( + key="swell_forecast", + name="Forecast", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + UNITS = ["eu", "uk", "us"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_SPOT_ID): vol.All(cv.ensure_list, [cv.string]), @@ -78,67 +96,59 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if forecast_data.currently is None or forecast_data.hourly is None: return - sensors = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - sensors.append(MagicSeaweedSensor(forecast_data, variable, name, units)) - if "forecast" not in variable and hours is not None: - for hour in hours: - sensors.append( - MagicSeaweedSensor(forecast_data, variable, name, units, hour) - ) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + sensors = [ + MagicSeaweedSensor(forecast_data, name, units, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + sensors.extend( + [ + MagicSeaweedSensor(forecast_data, name, units, description, hour) + for description in SENSOR_TYPES + if description.key in monitored_conditions + and "forecast" not in description.key + for hour in hours + if hour is not None + ] + ) add_entities(sensors, True) class MagicSeaweedSensor(SensorEntity): """Implementation of a MagicSeaweed sensor.""" - def __init__(self, forecast_data, sensor_type, name, unit_system, hour=None): + _attr_icon = ICON + + def __init__( + self, + forecast_data, + name, + unit_system, + description: SensorEntityDescription, + hour=None, + ): """Initialize the sensor.""" + self.entity_description = description self.client_name = name self.data = forecast_data self.hour = hour - self.type = sensor_type - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._name = SENSOR_TYPES[sensor_type][0] - self._icon = None - self._state = None self._unit_system = unit_system - self._unit_of_measurement = None - @property - def name(self): - """Return the name of the sensor.""" - if self.hour is None and "forecast" in self.type: - return f"{self.client_name} {self._name}" - if self.hour is None: - return f"Current {self.client_name} {self._name}" - return f"{self.hour} {self.client_name} {self._name}" + if hour is None and "forecast" in description.key: + self._attr_name = f"{name} {description.name}" + elif hour is None: + self._attr_name = f"Current {name} {description.name}" + else: + self._attr_name = f"{hour} {name} {description.name}" - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} @property def unit_system(self): """Return the unit system of this entity.""" return self._unit_system - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the entity weather icon, if any.""" - return ICON - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attrs - def update(self): """Get the latest data from Magicseaweed and updates the states.""" self.data.update() @@ -147,22 +157,23 @@ class MagicSeaweedSensor(SensorEntity): else: forecast = self.data.hourly[self.hour] - self._unit_of_measurement = forecast.swell_unit - if self.type == "min_breaking_swell": - self._state = forecast.swell_minBreakingHeight - elif self.type == "max_breaking_swell": - self._state = forecast.swell_maxBreakingHeight - elif self.type == "swell_forecast": + self._attr_native_unit_of_measurement = forecast.swell_unit + sensor_type = self.entity_description.key + if sensor_type == "min_breaking_swell": + self._attr_native_value = forecast.swell_minBreakingHeight + elif sensor_type == "max_breaking_swell": + self._attr_native_value = forecast.swell_maxBreakingHeight + elif sensor_type == "swell_forecast": summary = f"{forecast.swell_minBreakingHeight} - {forecast.swell_maxBreakingHeight}" - self._state = summary + self._attr_native_value = summary if self.hour is None: for hour, data in self.data.hourly.items(): occurs = hour hr_summary = f"{data.swell_minBreakingHeight} - {data.swell_maxBreakingHeight} {data.swell_unit}" - self._attrs[occurs] = hr_summary + self._attr_extra_state_attributes[occurs] = hr_summary - if self.type != "swell_forecast": - self._attrs.update(forecast.attrs) + if sensor_type != "swell_forecast": + self._attr_extra_state_attributes.update(forecast.attrs) class MagicSeaweedData: diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 0f75fbcee35..5904e271a6a 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -1,4 +1,6 @@ """Support for the MaryTTS service.""" +from __future__ import annotations + from speak2mary import MaryTTS import voluptuous as vol @@ -19,7 +21,7 @@ DEFAULT_PORT = 59125 DEFAULT_LANG = "en_US" DEFAULT_VOICE = "cmu-slt-hsmm" DEFAULT_CODEC = "WAVE_FILE" -DEFAULT_EFFECTS = {} +DEFAULT_EFFECTS: dict[str, str] = {} MAP_MARYTTS_CODEC = {"WAVE_FILE": "wav", "AIFF_FILE": "aiff", "AU_FILE": "au"} diff --git a/homeassistant/components/matrix/services.yaml b/homeassistant/components/matrix/services.yaml index 66988def22d..c58a27c3370 100644 --- a/homeassistant/components/matrix/services.yaml +++ b/homeassistant/components/matrix/services.yaml @@ -21,4 +21,4 @@ send_message: description: Extended information of notification. Supports list of images. Optional. example: "{'images': ['/tmp/test.jpg']}" selector: - text: + object: diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 175f44b9d0e..d3dd780134a 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -119,7 +119,7 @@ class MaxCubeClimate(ClimateEntity): def hvac_mode(self): """Return current operation mode.""" mode = self._device.mode - if mode in [MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_BOOST]: + if mode in (MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_BOOST): return HVAC_MODE_AUTO if ( mode == MAX_DEVICE_MODE_MANUAL diff --git a/homeassistant/components/mazda/sensor.py b/homeassistant/components/mazda/sensor.py index 673c965544b..03bfbd23b31 100644 --- a/homeassistant/components/mazda/sensor.py +++ b/homeassistant/components/mazda/sensor.py @@ -46,7 +46,7 @@ class MazdaFuelRemainingSensor(MazdaEntity, SensorEntity): return f"{self.vin}_fuel_remaining_percentage" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE @@ -56,7 +56,7 @@ class MazdaFuelRemainingSensor(MazdaEntity, SensorEntity): return "mdi:gas-station" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.data["status"]["fuelRemainingPercent"] @@ -76,7 +76,7 @@ class MazdaFuelDistanceSensor(MazdaEntity, SensorEntity): return f"{self.vin}_fuel_distance_remaining" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: return LENGTH_MILES @@ -88,7 +88,7 @@ class MazdaFuelDistanceSensor(MazdaEntity, SensorEntity): return "mdi:gas-station" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" fuel_distance_km = self.data["status"]["fuelDistanceRemainingKm"] return ( @@ -115,7 +115,7 @@ class MazdaOdometerSensor(MazdaEntity, SensorEntity): return f"{self.vin}_odometer" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: return LENGTH_MILES @@ -127,7 +127,7 @@ class MazdaOdometerSensor(MazdaEntity, SensorEntity): return "mdi:speedometer" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" odometer_km = self.data["status"]["odometerKm"] return ( @@ -152,7 +152,7 @@ class MazdaFrontLeftTirePressureSensor(MazdaEntity, SensorEntity): return f"{self.vin}_front_left_tire_pressure" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PRESSURE_PSI @@ -162,7 +162,7 @@ class MazdaFrontLeftTirePressureSensor(MazdaEntity, SensorEntity): return "mdi:car-tire-alert" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" tire_pressure = self.data["status"]["tirePressure"]["frontLeftTirePressurePsi"] return None if tire_pressure is None else round(tire_pressure) @@ -183,7 +183,7 @@ class MazdaFrontRightTirePressureSensor(MazdaEntity, SensorEntity): return f"{self.vin}_front_right_tire_pressure" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PRESSURE_PSI @@ -193,7 +193,7 @@ class MazdaFrontRightTirePressureSensor(MazdaEntity, SensorEntity): return "mdi:car-tire-alert" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" tire_pressure = self.data["status"]["tirePressure"]["frontRightTirePressurePsi"] return None if tire_pressure is None else round(tire_pressure) @@ -214,7 +214,7 @@ class MazdaRearLeftTirePressureSensor(MazdaEntity, SensorEntity): return f"{self.vin}_rear_left_tire_pressure" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PRESSURE_PSI @@ -224,7 +224,7 @@ class MazdaRearLeftTirePressureSensor(MazdaEntity, SensorEntity): return "mdi:car-tire-alert" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" tire_pressure = self.data["status"]["tirePressure"]["rearLeftTirePressurePsi"] return None if tire_pressure is None else round(tire_pressure) @@ -245,7 +245,7 @@ class MazdaRearRightTirePressureSensor(MazdaEntity, SensorEntity): return f"{self.vin}_rear_right_tire_pressure" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PRESSURE_PSI @@ -255,7 +255,7 @@ class MazdaRearRightTirePressureSensor(MazdaEntity, SensorEntity): return "mdi:car-tire-alert" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" tire_pressure = self.data["status"]["tirePressure"]["rearRightTirePressurePsi"] return None if tire_pressure is None else round(tire_pressure) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 6978b90c897..b0030786ed7 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -813,11 +813,10 @@ class MediaPlayerEntity(Entity): async def async_toggle(self): """Toggle the power on the media player.""" if hasattr(self, "toggle"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.toggle) return - if self.state in [STATE_OFF, STATE_IDLE]: + if self.state in (STATE_OFF, STATE_IDLE): await self.async_turn_on() else: await self.async_turn_off() @@ -828,7 +827,6 @@ class MediaPlayerEntity(Entity): This method is a coroutine. """ if hasattr(self, "volume_up"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.volume_up) return @@ -841,7 +839,6 @@ class MediaPlayerEntity(Entity): This method is a coroutine. """ if hasattr(self, "volume_down"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.volume_down) return @@ -851,7 +848,6 @@ class MediaPlayerEntity(Entity): async def async_media_play_pause(self): """Play or pause the media player.""" if hasattr(self, "media_play_pause"): - # pylint: disable=no-member await self.hass.async_add_executor_job(self.media_play_pause) return diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index de0ff6b8e90..532519616d2 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Media player.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -36,7 +38,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Media player entities.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -61,7 +65,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index 115d6da447d..79688130a36 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -63,12 +63,12 @@ async def _async_reproduce_states( # entities that are off have no other attributes to restore return - if state.state in [ + if state.state in ( STATE_ON, STATE_PLAYING, STATE_IDLE, STATE_PAUSED, - ]: + ): await call_service(SERVICE_TURN_ON, []) if ATTR_MEDIA_VOLUME_LEVEL in state.attributes: diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 5b027a99bf9..cb485ac765f 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from . import local_source, models @@ -36,7 +37,7 @@ def generate_media_source_id(domain: str, identifier: str) -> str: return uri -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media_source component.""" hass.data[DOMAIN] = {} hass.components.websocket_api.async_register_command(websocket_browse_media) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 12b80554933..69efa26ac44 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import DOMAIN @@ -44,7 +45,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigEntry): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Establish connection with MELCloud.""" if DOMAIN not in config: return True diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 6c303e8e3c3..608c3547724 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -11,11 +11,11 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) from homeassistant.const import ENERGY_KILO_WATT_HOUR, TEMP_CELSIUS -from homeassistant.util import dt as dt_util from . import MelCloudDevice from .const import DOMAIN @@ -41,7 +41,7 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="room_temperature", name="Room Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda x: x.device.room_temperature, enabled=lambda x: True, @@ -50,7 +50,7 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="energy", name="Energy", icon="mdi:factory", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, value_fn=lambda x: x.device.total_energy_consumed, enabled=lambda x: x.device.has_energy_consumed_meter, @@ -61,7 +61,7 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="outside_temperature", name="Outside Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda x: x.device.outside_temperature, enabled=lambda x: True, @@ -70,7 +70,7 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="tank_temperature", name="Tank Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda x: x.device.tank_temperature, enabled=lambda x: True, @@ -81,7 +81,7 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="room_temperature", name="Room Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda zone: zone.room_temperature, enabled=lambda x: True, @@ -90,7 +90,7 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="flow_temperature", name="Flow Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda zone: zone.flow_temperature, enabled=lambda x: True, @@ -99,7 +99,7 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( key="return_temperature", name="Flow Return Temperature", icon="mdi:thermometer", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, value_fn=lambda zone: zone.return_temperature, enabled=lambda x: True, @@ -150,13 +150,14 @@ class MelDeviceSensor(SensorEntity): self._attr_name = f"{api.name} {description.name}" self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{description.key}" - self._attr_state_class = STATE_CLASS_MEASUREMENT if description.device_class == DEVICE_CLASS_ENERGY: - self._attr_last_reset = dt_util.utc_from_timestamp(0) + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + else: + self._attr_state_class = STATE_CLASS_MEASUREMENT @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.entity_description.value_fn(self._api) @@ -187,6 +188,6 @@ class AtwZoneSensor(MelDeviceSensor): self._attr_name = f"{api.name} {zone.name} {description.name}" @property - def state(self): + def native_value(self): """Return zone based state.""" return self.entity_description.value_fn(self._zone) diff --git a/homeassistant/components/melcloud/translations/hu.json b/homeassistant/components/melcloud/translations/hu.json index 7f81269c700..5744b71c780 100644 --- a/homeassistant/components/melcloud/translations/hu.json +++ b/homeassistant/components/melcloud/translations/hu.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "A MELCloud integr\u00e1ci\u00f3 m\u00e1r be van \u00e1ll\u00edtva ehhez az e-mailhez. A hozz\u00e1f\u00e9r\u00e9si token friss\u00edtve lett." + }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", @@ -10,7 +13,9 @@ "data": { "password": "Jelsz\u00f3", "username": "E-mail" - } + }, + "description": "Csatlakozzon a MELCloud-fi\u00f3kj\u00e1val.", + "title": "Csatlakozzon a MELCloudhoz" } } } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index b710686554f..df006c78194 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -56,7 +56,7 @@ async def async_setup_entry( if coordinator_alert: entities.append(MeteoFranceAlertSensor(sensor_type, coordinator_alert)) - elif sensor_type in ["rain_chance", "freeze_chance", "snow_chance"]: + elif sensor_type in ("rain_chance", "freeze_chance", "snow_chance"): if coordinator_forecast.data.probability_forecast: entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) else: @@ -109,7 +109,7 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): } @property - def state(self): + def native_value(self): """Return the state.""" path = SENSOR_TYPES[self._type][ENTITY_API_DATA_PATH].split(":") data = getattr(self.coordinator.data, path[0]) @@ -129,13 +129,13 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): else: value = data[path[1]] - if self._type in ["wind_speed", "wind_gust"]: + if self._type in ("wind_speed", "wind_gust"): # convert API wind speed from m/s to km/h value = round(value * 3.6) return value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return SENSOR_TYPES[self._type][ENTITY_UNIT] @@ -164,7 +164,7 @@ class MeteoFranceRainSensor(MeteoFranceSensor): """Representation of a Meteo-France rain sensor.""" @property - def state(self): + def native_value(self): """Return the state.""" # search first cadran with rain next_rain = next( @@ -202,7 +202,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor): self._unique_id = self._name @property - def state(self): + def native_value(self): """Return the state.""" return get_warning_text_status_from_indice_color( self.coordinator.data.get_domain_max_color() diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 101b889498d..b5a07ad06e6 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -51,7 +51,9 @@ class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): f"{station.name} {SENSOR_TYPES[sensor_type][SENSOR_TYPE_NAME]}" ) self._attr_unique_id = f"{station.code}_{sensor_type}" - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type].get(SENSOR_TYPE_UNIT) + self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type].get( + SENSOR_TYPE_UNIT + ) @property def device_info(self): @@ -65,7 +67,7 @@ class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): } @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return ( getattr(self.coordinator.data["weather"], self._type) diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 749282b1a21..4919e36bd58 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -42,7 +42,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="name", name="Station Name", device_class=None, - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:label-outline", entity_registry_enabled_default=False, ), @@ -50,7 +50,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="weather", name="Weather", device_class=None, - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:weather-sunny", # but will adapt to current conditions entity_registry_enabled_default=True, ), @@ -58,7 +58,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="temperature", name="Temperature", device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, entity_registry_enabled_default=True, ), @@ -66,7 +66,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="feels_like_temperature", name="Feels Like Temperature", device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, entity_registry_enabled_default=False, ), @@ -74,7 +74,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wind_speed", name="Wind Speed", device_class=None, - unit_of_measurement=SPEED_MILES_PER_HOUR, + native_unit_of_measurement=SPEED_MILES_PER_HOUR, icon="mdi:weather-windy", entity_registry_enabled_default=True, ), @@ -82,7 +82,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wind_direction", name="Wind Direction", device_class=None, - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:compass-outline", entity_registry_enabled_default=False, ), @@ -90,7 +90,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wind_gust", name="Wind Gust", device_class=None, - unit_of_measurement=SPEED_MILES_PER_HOUR, + native_unit_of_measurement=SPEED_MILES_PER_HOUR, icon="mdi:weather-windy", entity_registry_enabled_default=False, ), @@ -98,7 +98,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="visibility", name="Visibility", device_class=None, - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:eye", entity_registry_enabled_default=False, ), @@ -106,7 +106,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="visibility_distance", name="Visibility Distance", device_class=None, - unit_of_measurement=LENGTH_KILOMETERS, + native_unit_of_measurement=LENGTH_KILOMETERS, icon="mdi:eye", entity_registry_enabled_default=False, ), @@ -114,7 +114,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="uv", name="UV Index", device_class=None, - unit_of_measurement=UV_INDEX, + native_unit_of_measurement=UV_INDEX, icon="mdi:weather-sunny-alert", entity_registry_enabled_default=True, ), @@ -122,7 +122,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="precipitation", name="Probability of Precipitation", device_class=None, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-rainy", entity_registry_enabled_default=True, ), @@ -130,7 +130,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="humidity", name="Humidity", device_class=DEVICE_CLASS_HUMIDITY, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=None, entity_registry_enabled_default=False, ), @@ -189,7 +189,7 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): self.use_3hourly = use_3hourly @property - def state(self): + def native_value(self): """Return the state of the sensor.""" value = None diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py index fafaf53ff99..b27f719d974 100644 --- a/homeassistant/components/mfi/sensor.py +++ b/homeassistant/components/mfi/sensor.py @@ -88,7 +88,7 @@ class MfiSensor(SensorEntity): return self._port.label @property - def state(self): + def native_value(self): """Return the state of the sensor.""" try: tag = self._port.tag @@ -115,7 +115,7 @@ class MfiSensor(SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" try: tag = self._port.tag diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py index 63a1181f720..a599a9f7dfb 100644 --- a/homeassistant/components/mhz19/sensor.py +++ b/homeassistant/components/mhz19/sensor.py @@ -1,11 +1,17 @@ """Support for CO2 sensor connected to a serial port.""" +from __future__ import annotations + from datetime import timedelta import logging from pmsensor import co2sensor 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_TEMPERATURE, CONCENTRATION_PARTS_PER_MILLION, @@ -13,11 +19,10 @@ from homeassistant.const import ( CONF_NAME, DEVICE_CLASS_CO2, DEVICE_CLASS_TEMPERATURE, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.temperature import celsius_to_fahrenheit _LOGGER = logging.getLogger(__name__) @@ -30,16 +35,27 @@ ATTR_CO2_CONCENTRATION = "co2_concentration" SENSOR_TEMPERATURE = "temperature" SENSOR_CO2 = "co2" -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], - SENSOR_CO2: ["CO2", CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key=SENSOR_CO2, + name="CO2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2, + ), +) +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SERIAL_DEVICE): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=[SENSOR_CO2]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -57,64 +73,54 @@ def setup_platform(hass, config, add_entities, discovery_info=None): err, ) return False - SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit data = MHZClient(co2sensor, config.get(CONF_SERIAL_DEVICE)) - dev = [] - name = config.get(CONF_NAME) + name = config[CONF_NAME] - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append(MHZ19Sensor(data, variable, SENSOR_TYPES[variable][1], name)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + MHZ19Sensor(data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - add_entities(dev, True) - return True + add_entities(entities, True) class MHZ19Sensor(SensorEntity): """Representation of an CO2 sensor.""" - def __init__(self, mhz_client, sensor_type, temp_unit, name): + def __init__(self, mhz_client, name, description: SensorEntityDescription): """Initialize a new PM sensor.""" + self.entity_description = description self._mhz_client = mhz_client - self._sensor_type = sensor_type - self._temp_unit = temp_unit - self._name = name - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._ppm = None self._temperature = None - self._attr_device_class = SENSOR_TYPES[sensor_type][2] + + self._attr_name = f"{name}: {description.name}" @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name}: {SENSOR_TYPES[self._sensor_type][0]}" - - @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - return self._ppm if self._sensor_type == SENSOR_CO2 else self._temperature - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + if self.entity_description.key == SENSOR_CO2: + return self._ppm + return self._temperature def update(self): """Read from sensor and update the state.""" self._mhz_client.update() data = self._mhz_client.data self._temperature = data.get(SENSOR_TEMPERATURE) - if self._temperature is not None and self._temp_unit == TEMP_FAHRENHEIT: - self._temperature = round(celsius_to_fahrenheit(self._temperature), 1) self._ppm = data.get(SENSOR_CO2) @property def extra_state_attributes(self): """Return the state attributes.""" result = {} - if self._sensor_type == SENSOR_TEMPERATURE and self._ppm is not None: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TEMPERATURE and self._ppm is not None: result[ATTR_CO2_CONCENTRATION] = self._ppm - if self._sensor_type == SENSOR_CO2 and self._temperature is not None: + elif sensor_type == SENSOR_CO2 and self._temperature is not None: result[ATTR_TEMPERATURE] = self._temperature return result diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py index a7aab41bea9..0e9abe5c757 100644 --- a/homeassistant/components/miflora/sensor.py +++ b/homeassistant/components/miflora/sensor.py @@ -1,7 +1,9 @@ """Support for Xiaomi Mi Flora BLE plant sensor.""" +from __future__ import annotations from datetime import timedelta import logging +from typing import Any import btlewrap from btlewrap import BluetoothBackendException @@ -12,6 +14,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( CONDUCTIVITY, @@ -27,12 +30,10 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, TEMP_CELSIUS, - TEMP_FAHRENHEIT, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -from homeassistant.util.temperature import celsius_to_fahrenheit try: import bluepy.btle # noqa: F401 pylint: disable=unused-import @@ -57,20 +58,46 @@ SCAN_INTERVAL = timedelta(seconds=1200) ATTR_LAST_SUCCESSFUL_UPDATE = "last_successful_update" -# Sensor types are defined like: Name, units, icon, device_class -SENSOR_TYPES = { - "temperature": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "light": ["Light intensity", LIGHT_LUX, None, DEVICE_CLASS_ILLUMINANCE], - "moisture": ["Moisture", PERCENTAGE, "mdi:water-percent", None], - "conductivity": ["Conductivity", CONDUCTIVITY, "mdi:flash-circle", None], - "battery": ["Battery", PERCENTAGE, None, DEVICE_CLASS_BATTERY], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="light", + name="Light intensity", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + ), + SensorEntityDescription( + key="moisture", + name="Moisture", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + SensorEntityDescription( + key="conductivity", + name="Conductivity", + native_unit_of_measurement=CONDUCTIVITY, + icon="mdi:flash-circle", + ), + SensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MAC): cv.string, - 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, vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int, @@ -90,74 +117,55 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= cache = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL).total_seconds() poller = miflora_poller.MiFloraPoller( - config.get(CONF_MAC), + config[CONF_MAC], cache_timeout=cache, - adapter=config.get(CONF_ADAPTER), + adapter=config[CONF_ADAPTER], backend=backend, ) - force_update = config.get(CONF_FORCE_UPDATE) - median = config.get(CONF_MEDIAN) + force_update = config[CONF_FORCE_UPDATE] + median = config[CONF_MEDIAN] - go_unavailable_timeout = config.get(CONF_GO_UNAVAILABLE_TIMEOUT) + go_unavailable_timeout = config[CONF_GO_UNAVAILABLE_TIMEOUT] - devs = [] - - for parameter in config[CONF_MONITORED_CONDITIONS]: - name = SENSOR_TYPES[parameter][0] - unit = ( - hass.config.units.temperature_unit - if parameter == "temperature" - else SENSOR_TYPES[parameter][1] + prefix = config[CONF_NAME] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + MiFloraSensor( + description, + poller, + prefix, + force_update, + median, + go_unavailable_timeout, ) - icon = SENSOR_TYPES[parameter][2] - device_class = SENSOR_TYPES[parameter][3] + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - prefix = config.get(CONF_NAME) - if prefix: - name = f"{prefix} {name}" - - devs.append( - MiFloraSensor( - poller, - parameter, - name, - unit, - icon, - device_class, - force_update, - median, - go_unavailable_timeout, - ) - ) - - async_add_entities(devs) + async_add_entities(entities) class MiFloraSensor(SensorEntity): """Implementing the MiFlora sensor.""" + _attr_state_class = STATE_CLASS_MEASUREMENT + def __init__( self, + description: SensorEntityDescription, poller, - parameter, - name, - unit, - icon, - device_class, + prefix, force_update, median, go_unavailable_timeout, ): """Initialize the sensor.""" + self.entity_description = description self.poller = poller - self.parameter = parameter - self._unit = unit - self._icon = icon - self._name = name - self._state = None - self._device_class = device_class - self.data = [] - self._force_update = force_update + self.data: list[Any] = [] + if prefix: + self._attr_name = f"{prefix} {description.name}" + self._attr_force_update = force_update self.go_unavailable_timeout = go_unavailable_timeout self.last_successful_update = dt_util.utc_from_timestamp(0) # Median is used to filter out outliers. median of 3 will filter @@ -174,16 +182,6 @@ class MiFloraSensor(SensorEntity): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, on_startup) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - @property def available(self): """Return True if did update since 2h.""" @@ -196,31 +194,6 @@ class MiFloraSensor(SensorEntity): """Return the state attributes of the device.""" return {ATTR_LAST_SUCCESSFUL_UPDATE: self.last_successful_update} - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def state_class(self): - """Return the state class of this entity.""" - return STATE_CLASS_MEASUREMENT - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit - - @property - def icon(self): - """Return the icon of the sensor.""" - return self._icon - - @property - def force_update(self): - """Force update.""" - return self._force_update - def update(self): """ Update current conditions. @@ -229,15 +202,13 @@ class MiFloraSensor(SensorEntity): """ try: _LOGGER.debug("Polling data for %s", self.name) - data = self.poller.parameter_value(self.parameter) + data = self.poller.parameter_value(self.entity_description.key) except (OSError, BluetoothBackendException) as err: _LOGGER.info("Polling error %s: %s", type(err).__name__, err) return if data is not None: _LOGGER.debug("%s = %s", self.name, data) - if self._unit == TEMP_FAHRENHEIT: - data = celsius_to_fahrenheit(data) self.data.append(data) self.last_successful_update = dt_util.utcnow() else: @@ -247,7 +218,7 @@ class MiFloraSensor(SensorEntity): if self.data: self.data = self.data[1:] else: - self._state = None + self._attr_native_value = None return _LOGGER.debug("Data collected: %s", self.data) @@ -257,9 +228,9 @@ class MiFloraSensor(SensorEntity): if len(self.data) == self.median_count: median = sorted(self.data)[int((self.median_count - 1) / 2)] _LOGGER.debug("Median is: %s", median) - self._state = median - elif self._state is None: + self._attr_native_value = median + elif self._attr_native_value is None: _LOGGER.debug("Set initial state") - self._state = self.data[0] + self._attr_native_value = self.data[0] else: _LOGGER.debug("Not yet enough data for median calculation") diff --git a/homeassistant/components/mikrotik/translations/zh-Hans.json b/homeassistant/components/mikrotik/translations/zh-Hans.json index 9604af53495..14916be1264 100644 --- a/homeassistant/components/mikrotik/translations/zh-Hans.json +++ b/homeassistant/components/mikrotik/translations/zh-Hans.json @@ -1,14 +1,33 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548", + "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + }, "step": { "user": { "data": { "host": "\u4e3b\u673a", - "name": "\u540d\u5b57", + "name": "\u540d\u79f0", "password": "\u5bc6\u7801", "port": "\u7aef\u53e3", "username": "\u7528\u6237\u540d", - "verify_ssl": "\u4f7f\u7528 ssl" + "verify_ssl": "\u4f7f\u7528 SSL" + }, + "title": "\u8bbe\u7f6e Mikrotik \u8def\u7531\u5668" + } + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "\u542f\u7528 ARP Ping", + "force_dhcp": "\u4f7f\u7528 DHCP \u5f3a\u5236\u626b\u63cf" } } } diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 75422cd26e1..73cb65daf05 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -1,15 +1,43 @@ """The mill component.""" +from datetime import timedelta +import logging + from mill import Mill from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + PLATFORMS = ["climate", "sensor"] +class MillDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Mill data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + mill_data_connection: Mill, + ) -> None: + """Initialize global Mill data updater.""" + self.mill_data_connection = mill_data_connection + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_method=mill_data_connection.fetch_heater_data, + update_interval=timedelta(seconds=30), + ) + + async def async_setup_entry(hass, entry): """Set up the Mill heater.""" mill_data_connection = Mill( @@ -20,9 +48,12 @@ async def async_setup_entry(hass, entry): if not await mill_data_connection.connect(): raise ConfigEntryNotReady - await mill_data_connection.find_all_heaters() + hass.data[DOMAIN] = MillDataUpdateCoordinator( + hass, + mill_data_connection=mill_data_connection, + ) - hass.data[DOMAIN] = mill_data_connection + await hass.data[DOMAIN].async_config_entry_first_refresh() hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 16c78329b0b..199bdf393a1 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -11,8 +11,10 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_AWAY_TEMP, @@ -41,11 +43,11 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema( async def async_setup_entry(hass, entry, async_add_entities): """Set up the Mill climate.""" - mill_data_connection = hass.data[DOMAIN] + mill_data_coordinator = hass.data[DOMAIN] dev = [] - for heater in mill_data_connection.heaters.values(): - dev.append(MillHeater(heater, mill_data_connection)) + for heater in mill_data_coordinator.data.values(): + dev.append(MillHeater(mill_data_coordinator, heater)) async_add_entities(dev) async def set_room_temp(service): @@ -54,7 +56,7 @@ async def async_setup_entry(hass, entry, async_add_entities): sleep_temp = service.data.get(ATTR_SLEEP_TEMP) comfort_temp = service.data.get(ATTR_COMFORT_TEMP) away_temp = service.data.get(ATTR_AWAY_TEMP) - await mill_data_connection.set_room_temperatures_by_name( + await mill_data_coordinator.mill_data_connection.set_room_temperatures_by_name( room_name, sleep_temp, comfort_temp, away_temp ) @@ -63,122 +65,97 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class MillHeater(ClimateEntity): +class MillHeater(CoordinatorEntity, ClimateEntity): """Representation of a Mill Thermostat device.""" _attr_fan_modes = [FAN_ON, HVAC_MODE_OFF] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_supported_features = SUPPORT_FLAGS - _attr_target_temperature_step = 1 + _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = TEMP_CELSIUS - def __init__(self, heater, mill_data_connection): + def __init__(self, coordinator, heater): """Initialize the thermostat.""" - self._heater = heater - self._conn = mill_data_connection + super().__init__(coordinator) + + self._id = heater.device_id self._attr_unique_id = heater.device_id self._attr_name = heater.name - - @property - def available(self): - """Return True if entity is available.""" - return self._heater.available - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - res = { - "open_window": self._heater.open_window, - "heating": self._heater.is_heating, - "controlled_by_tibber": self._heater.tibber_control, - "heater_generation": 1 if self._heater.is_gen1 else 2, + self._attr_device_info = { + "identifiers": {(DOMAIN, heater.device_id)}, + "name": self.name, + "manufacturer": MANUFACTURER, + "model": f"generation {1 if heater.is_gen1 else 2}", } - if self._heater.room: - res["room"] = self._heater.room.name - res["avg_room_temp"] = self._heater.room.avg_temp + if heater.is_gen1: + self._attr_hvac_modes = [HVAC_MODE_HEAT] else: - res["room"] = "Independent device" - return res - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._heater.set_temp - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._heater.current_temp - - @property - def fan_mode(self): - """Return the fan setting.""" - return FAN_ON if self._heater.fan_status == 1 else HVAC_MODE_OFF - - @property - def hvac_action(self): - """Return current hvac i.e. heat, cool, idle.""" - if self._heater.is_gen1 or self._heater.is_heating == 1: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE - - @property - def hvac_mode(self) -> str: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self._heater.is_gen1 or self._heater.power_status == 1: - return HVAC_MODE_HEAT - 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. - """ - if self._heater.is_gen1: - return [HVAC_MODE_HEAT] - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + self._attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + self._update_attr(heater) async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - await self._conn.set_heater_temp(self._heater.device_id, int(temperature)) + await self.coordinator.mill_data_connection.set_heater_temp( + self._id, int(temperature) + ) + await self.coordinator.async_request_refresh() async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" fan_status = 1 if fan_mode == FAN_ON else 0 - await self._conn.heater_control(self._heater.device_id, fan_status=fan_status) + await self.coordinator.mill_data_connection.heater_control( + self._id, fan_status=fan_status + ) + await self.coordinator.async_request_refresh() async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" + heater = self.coordinator.data[self._id] + if hvac_mode == HVAC_MODE_HEAT: - await self._conn.heater_control(self._heater.device_id, power_status=1) - elif hvac_mode == HVAC_MODE_OFF and not self._heater.is_gen1: - await self._conn.heater_control(self._heater.device_id, power_status=0) + await self.coordinator.mill_data_connection.heater_control( + self._id, power_status=1 + ) + await self.coordinator.async_request_refresh() + elif hvac_mode == HVAC_MODE_OFF and not heater.is_gen1: + await self.coordinator.mill_data_connection.heater_control( + self._id, power_status=0 + ) + await self.coordinator.async_request_refresh() - async def async_update(self): - """Retrieve latest state.""" - self._heater = await self._conn.update_device(self._heater.device_id) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr(self.coordinator.data[self._id]) + self.async_write_ha_state() - @property - def device_id(self): - """Return the ID of the physical device this sensor is part of.""" - return self._heater.device_id - - @property - def device_info(self): - """Return the device_info of the device.""" - device_info = { - "identifiers": {(DOMAIN, self.device_id)}, - "name": self.name, - "manufacturer": MANUFACTURER, - "model": f"generation {1 if self._heater.is_gen1 else 2}", + @callback + def _update_attr(self, heater): + self._attr_available = heater.available + self._attr_extra_state_attributes = { + "open_window": heater.open_window, + "heating": heater.is_heating, + "controlled_by_tibber": heater.tibber_control, + "heater_generation": 1 if heater.is_gen1 else 2, } - return device_info + if heater.room: + self._attr_extra_state_attributes["room"] = heater.room.name + self._attr_extra_state_attributes["avg_room_temp"] = heater.room.avg_temp + else: + self._attr_extra_state_attributes["room"] = "Independent device" + self._attr_target_temperature = heater.set_temp + self._attr_current_temperature = heater.current_temp + self._attr_fan_mode = FAN_ON if heater.fan_status == 1 else HVAC_MODE_OFF + if heater.is_gen1 or heater.is_heating == 1: + self._attr_hvac_action = CURRENT_HVAC_HEAT + else: + self._attr_hvac_action = CURRENT_HVAC_IDLE + if heater.is_gen1 or heater.power_status == 1: + self._attr_hvac_mode = HVAC_MODE_HEAT + else: + self._attr_hvac_mode = HVAC_MODE_OFF diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 161bbe274ef..33a7c35c169 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.0"], + "requirements": ["millheater==0.5.2"], "codeowners": ["@danielhiversen"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 8b68d0ebe38..11b006e4b6e 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -2,11 +2,12 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) -from homeassistant.const import ENERGY_KILO_WATT_HOUR, STATE_UNKNOWN -from homeassistant.util import dt as dt_util +from homeassistant.const import ENERGY_KILO_WATT_HOUR +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONSUMPTION_TODAY, CONSUMPTION_YEAR, DOMAIN, MANUFACTURER @@ -14,72 +15,51 @@ from .const import CONSUMPTION_TODAY, CONSUMPTION_YEAR, DOMAIN, MANUFACTURER async def async_setup_entry(hass, entry, async_add_entities): """Set up the Mill sensor.""" - mill_data_connection = hass.data[DOMAIN] + mill_data_coordinator = hass.data[DOMAIN] - dev = [] - for heater in mill_data_connection.heaters.values(): - for sensor_type in (CONSUMPTION_TODAY, CONSUMPTION_YEAR): - dev.append( - MillHeaterEnergySensor(heater, mill_data_connection, sensor_type) - ) - async_add_entities(dev) + entities = [ + MillHeaterEnergySensor(mill_data_coordinator, sensor_type, heater) + for sensor_type in (CONSUMPTION_TODAY, CONSUMPTION_YEAR) + for heater in mill_data_coordinator.data.values() + ] + async_add_entities(entities) -class MillHeaterEnergySensor(SensorEntity): +class MillHeaterEnergySensor(CoordinatorEntity, SensorEntity): """Representation of a Mill Sensor device.""" - def __init__(self, heater, mill_data_connection, sensor_type): + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_state_class = STATE_CLASS_TOTAL_INCREASING + + def __init__(self, coordinator, sensor_type, heater): """Initialize the sensor.""" + super().__init__(coordinator) + self._id = heater.device_id - self._conn = mill_data_connection self._sensor_type = sensor_type - self._attr_device_class = DEVICE_CLASS_ENERGY self._attr_name = f"{heater.name} {sensor_type.replace('_', ' ')}" self._attr_unique_id = f"{heater.device_id}_{sensor_type}" - self._attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR - self._attr_state_class = STATE_CLASS_MEASUREMENT self._attr_device_info = { "identifiers": {(DOMAIN, heater.device_id)}, "name": self.name, "manufacturer": MANUFACTURER, "model": f"generation {1 if heater.is_gen1 else 2}", } - if self._sensor_type == CONSUMPTION_TODAY: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - elif self._sensor_type == CONSUMPTION_YEAR: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace( - month=1, day=1, hour=0, minute=0, second=0, microsecond=0 - ) - ) + self._update_attr(heater) - async def async_update(self): - """Retrieve latest state.""" - heater = await self._conn.update_device(self._id) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr(self.coordinator.data[self._id]) + self.async_write_ha_state() + + @callback + def _update_attr(self, heater): self._attr_available = heater.available if self._sensor_type == CONSUMPTION_TODAY: - _state = heater.day_consumption + self._attr_native_value = heater.day_consumption elif self._sensor_type == CONSUMPTION_YEAR: - _state = heater.year_consumption - else: - _state = None - if _state is None: - self._attr_state = _state - return - - if self.state not in [STATE_UNKNOWN, None] and _state < self.state: - if self._sensor_type == CONSUMPTION_TODAY: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - elif self._sensor_type == CONSUMPTION_YEAR: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace( - month=1, day=1, hour=0, minute=0, second=0, microsecond=0 - ) - ) - self._attr_state = _state + self._attr_native_value = heater.year_consumption diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index d103ff8eaa6..e4b4cdf9922 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -172,7 +172,7 @@ class MinMaxSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._unit_of_measurement_mismatch: return None @@ -181,7 +181,7 @@ class MinMaxSensor(SensorEntity): ) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if self._unit_of_measurement_mismatch: return "ERR" diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 651c2762c55..9f1c89f09c6 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -70,12 +70,12 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): return self._server.online @property - def state(self) -> Any: + def native_value(self) -> Any: """Return sensor state.""" return self._state @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return sensor measurement unit.""" return self._unit diff --git a/homeassistant/components/minecraft_server/translations/hu.json b/homeassistant/components/minecraft_server/translations/hu.json index 247c1ffc1c3..ef3c228d2d5 100644 --- a/homeassistant/components/minecraft_server/translations/hu.json +++ b/homeassistant/components/minecraft_server/translations/hu.json @@ -4,7 +4,9 @@ "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 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.", + "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": { @@ -12,6 +14,7 @@ "host": "Hoszt", "name": "N\u00e9v" }, + "description": "\u00c1ll\u00edtsa be a Minecraft Server p\u00e9ld\u00e1nyt, hogy lehet\u0151v\u00e9 tegye a megfigyel\u00e9st.", "title": "Kapcsold \u00f6ssze a Minecraft szervered" } } diff --git a/homeassistant/components/minecraft_server/translations/zh-Hans.json b/homeassistant/components/minecraft_server/translations/zh-Hans.json new file mode 100644 index 00000000000..ef3c08c8434 --- /dev/null +++ b/homeassistant/components/minecraft_server/translations/zh-Hans.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230\u670d\u52a1\u5668\u3002\u8bf7\u68c0\u67e5\u4e3b\u673a\u5730\u5740\u548c\u7aef\u53e3\u5e76\u91cd\u8bd5\uff0c\u4e14\u786e\u4fdd\u60a8\u5728\u670d\u52a1\u5668\u4e0a\u8fd0\u884c\u7684 Minecraft \u7248\u672c\u81f3\u5c11\u5728 1.7 \u4ee5\u4e0a\u3002", + "invalid_ip": "IP \u5730\u5740\u65e0\u6548 (\u65e0\u6cd5\u786e\u5b9a MAC \u5730\u5740)\u3002\u8bf7\u66f4\u6b63\u5e76\u91cd\u8bd5\u3002", + "invalid_port": "\u7aef\u53e3\u7684\u8303\u56f4\u5728 1024 \u5230 65535 \u4e4b\u95f4\u3002\u8bf7\u66f4\u6b63\u5e76\u91cd\u8bd5\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0" + }, + "description": "\u8bbe\u7f6e\u60a8\u7684 Minecraft \u670d\u52a1\u5668\u5b9e\u4f8b\u4ee5\u5141\u8bb8\u76d1\u63a7\u3002", + "title": "\u8fde\u63a5\u60a8\u7684 Minecraft \u670d\u52a1\u5668" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py index 670a6daf3d3..ed6c7f27b94 100644 --- a/homeassistant/components/mitemp_bt/sensor.py +++ b/homeassistant/components/mitemp_bt/sensor.py @@ -1,12 +1,19 @@ """Support for Xiaomi Mi Temp BLE environmental sensor.""" +from __future__ import annotations + import logging +from typing import Any import btlewrap from btlewrap.base import BluetoothBackendException from mitemp_bt import mitemp_bt_poller 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_FORCE_UPDATE, CONF_MAC, @@ -44,18 +51,34 @@ DEFAULT_RETRIES = 2 DEFAULT_TIMEOUT = 10 -# Sensor types are defined like: Name, units -SENSOR_TYPES = { - "temperature": [DEVICE_CLASS_TEMPERATURE, "Temperature", TEMP_CELSIUS], - "humidity": [DEVICE_CLASS_HUMIDITY, "Humidity", PERCENTAGE], - "battery": [DEVICE_CLASS_BATTERY, "Battery", PERCENTAGE], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="battery", + name="Battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, + ), +) + +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MAC): cv.string, - 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, vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int, @@ -73,79 +96,46 @@ def setup_platform(hass, config, add_entities, discovery_info=None): backend = BACKEND _LOGGER.debug("MiTempBt is using %s backend", backend.__name__) - cache = config.get(CONF_CACHE) + cache = config[CONF_CACHE] poller = mitemp_bt_poller.MiTempBtPoller( - config.get(CONF_MAC), + config[CONF_MAC], cache_timeout=cache, - adapter=config.get(CONF_ADAPTER), + adapter=config[CONF_ADAPTER], backend=backend, ) - force_update = config.get(CONF_FORCE_UPDATE) - median = config.get(CONF_MEDIAN) - poller.ble_timeout = config.get(CONF_TIMEOUT) - poller.retries = config.get(CONF_RETRIES) + prefix = config[CONF_NAME] + force_update = config[CONF_FORCE_UPDATE] + median = config[CONF_MEDIAN] + poller.ble_timeout = config[CONF_TIMEOUT] + poller.retries = config[CONF_RETRIES] - devs = [] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + MiTempBtSensor(poller, prefix, force_update, median, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - for parameter in config[CONF_MONITORED_CONDITIONS]: - device = SENSOR_TYPES[parameter][0] - name = SENSOR_TYPES[parameter][1] - unit = SENSOR_TYPES[parameter][2] - - prefix = config.get(CONF_NAME) - if prefix: - name = f"{prefix} {name}" - - devs.append( - MiTempBtSensor(poller, parameter, device, name, unit, force_update, median) - ) - - add_entities(devs) + add_entities(entities) class MiTempBtSensor(SensorEntity): """Implementing the MiTempBt sensor.""" - def __init__(self, poller, parameter, device, name, unit, force_update, median): + def __init__( + self, poller, prefix, force_update, median, description: SensorEntityDescription + ): """Initialize the sensor.""" + self.entity_description = description self.poller = poller - self.parameter = parameter - self._device = device - self._unit = unit - self._name = name - self._state = None - self.data = [] - self._force_update = force_update + self.data: list[Any] = [] + self._attr_name = f"{prefix} {description.name}" + self._attr_force_update = force_update # Median is used to filter out outliers. median of 3 will filter # single outliers, while median of 5 will filter double outliers # Use median_count = 1 if no filtering is required. self.median_count = median - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit - - @property - def device_class(self): - """Device class of this entity.""" - return self._device - - @property - def force_update(self): - """Force update.""" - return self._force_update - def update(self): """ Update current conditions. @@ -154,7 +144,7 @@ class MiTempBtSensor(SensorEntity): """ try: _LOGGER.debug("Polling data for %s", self.name) - data = self.poller.parameter_value(self.parameter) + data = self.poller.parameter_value(self.entity_description.key) except OSError as ioerr: _LOGGER.warning("Polling error %s", ioerr) return @@ -174,7 +164,7 @@ class MiTempBtSensor(SensorEntity): if self.data: self.data = self.data[1:] else: - self._state = None + self._attr_native_value = None return if len(self.data) > self.median_count: @@ -183,6 +173,6 @@ class MiTempBtSensor(SensorEntity): if len(self.data) == self.median_count: median = sorted(self.data)[int((self.median_count - 1) / 2)] _LOGGER.debug("Median is: %s", median) - self._state = median + self._attr_native_value = median else: _LOGGER.debug("Not yet enough data for median calculation") diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index d5008d1778c..d486f78d334 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -1,4 +1,6 @@ """Support for IP Cameras.""" +from __future__ import annotations + import asyncio from contextlib import closing import logging @@ -106,7 +108,9 @@ class MjpegCamera(Camera): self._auth = aiohttp.BasicAuth(self._username, password=self._password) self._verify_ssl = device_info.get(CONF_VERIFY_SSL) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" # DigestAuth is not supported if ( @@ -130,11 +134,17 @@ class MjpegCamera(Camera): except aiohttp.ClientError as err: _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - def camera_image(self): + return None + + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" if self._username and self._password: if self._authentication == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(self._username, self._password) + auth: HTTPDigestAuth | HTTPBasicAuth = HTTPDigestAuth( + self._username, self._password + ) else: auth = HTTPBasicAuth(self._username, self._password) req = requests.get( diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 9633ec6556d..1fc5be2a890 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -36,7 +36,7 @@ from .webhook import handle_webhook PLATFORMS = "sensor", "binary_sensor", "device_tracker" -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the mobile app component.""" store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) app_config = await store.async_load() diff --git a/homeassistant/components/mobile_app/device_action.py b/homeassistant/components/mobile_app/device_action.py index 33a7510da21..193c25e482c 100644 --- a/homeassistant/components/mobile_app/device_action.py +++ b/homeassistant/components/mobile_app/device_action.py @@ -22,7 +22,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Mobile App devices.""" webhook_id = webhook_id_from_device_id(hass, device_id) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 162ec8afeab..c98fdeb9999 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -153,7 +153,7 @@ class MobileAppNotificationService(BaseNotificationService): ) result = await response.json() - if response.status in [HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED]: + if response.status in (HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED): log_rate_limits(self.hass, entry_data[ATTR_DEVICE_NAME], result) continue diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 7e3c1c13148..f6652f7f889 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -74,11 +74,11 @@ class MobileAppSensor(MobileAppEntity, SensorEntity): """Representation of an mobile app sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._config[ATTR_SENSOR_STATE] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._config.get(ATTR_SENSOR_UOM) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 43aa49e6da7..26d196f8af9 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -13,6 +13,7 @@ from homeassistant.components.cover import ( ) from homeassistant.components.sensor import ( DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, ) from homeassistant.components.switch import ( DEVICE_CLASSES_SCHEMA as SWITCH_DEVICE_CLASSES_SCHEMA, @@ -42,6 +43,7 @@ from homeassistant.const import ( CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from .const import ( @@ -64,6 +66,7 @@ from .const import ( CONF_DATA_TYPE, CONF_FANS, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_MSG_WAIT, @@ -72,9 +75,8 @@ from .const import ( CONF_RETRIES, CONF_RETRY_ON_EMPTY, CONF_REVERSE_ORDER, - CONF_RTUOVERTCP, CONF_SCALE, - CONF_SERIAL, + CONF_STATE_CLASS, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OFF, @@ -91,8 +93,6 @@ from .const import ( CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, - CONF_TCP, - CONF_UDP, CONF_VERIFY, CONF_WRITE_TYPE, DATA_TYPE_CUSTOM, @@ -113,9 +113,19 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_TEMP_UNIT, MODBUS_DOMAIN as DOMAIN, + RTUOVERTCP, + SERIAL, + TCP, + UDP, +) +from .modbus import ModbusHub, async_modbus_setup +from .validators import ( + duplicate_entity_validator, + duplicate_modbus_validator, + number_validator, + scan_interval_validator, + struct_validator, ) -from .modbus import async_modbus_setup -from .validators import number_validator, scan_interval_validator, struct_validator _LOGGER = logging.getLogger(__name__) @@ -130,6 +140,7 @@ BASE_COMPONENT_SCHEMA = vol.Schema( vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, + vol.Optional(CONF_LAZY_ERROR, default=0): cv.positive_int, } ) @@ -261,6 +272,7 @@ SENSOR_SCHEMA = vol.All( BASE_STRUCT_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_REVERSE_ORDER): cv.boolean, } @@ -303,7 +315,7 @@ MODBUS_SCHEMA = vol.Schema( SERIAL_SCHEMA = MODBUS_SCHEMA.extend( { - vol.Required(CONF_TYPE): CONF_SERIAL, + vol.Required(CONF_TYPE): SERIAL, vol.Required(CONF_BAUDRATE): cv.positive_int, vol.Required(CONF_BYTESIZE): vol.Any(5, 6, 7, 8), vol.Required(CONF_METHOD): vol.Any("rtu", "ascii"), @@ -317,7 +329,7 @@ ETHERNET_SCHEMA = MODBUS_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port, - vol.Required(CONF_TYPE): vol.Any(CONF_TCP, CONF_UDP, CONF_RTUOVERTCP), + vol.Required(CONF_TYPE): vol.Any(TCP, UDP, RTUOVERTCP), } ) @@ -326,6 +338,8 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.All( cv.ensure_list, scan_interval_validator, + duplicate_entity_validator, + duplicate_modbus_validator, [ vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA), ], @@ -357,6 +371,11 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema( ) +def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: + """Return modbus hub with name.""" + return hass.data[DOMAIN][name] + + async def async_setup(hass, config): """Set up Modbus component.""" return await async_modbus_setup( diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 288cfc7022a..efcb70b5b16 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -21,13 +21,15 @@ from homeassistant.const import ( CONF_STRUCTURE, STATE_ON, ) -from homeassistant.helpers.entity import Entity +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 ( CALL_TYPE_COIL, + CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, CALL_TYPE_WRITE_COIL, CALL_TYPE_WRITE_COILS, CALL_TYPE_WRITE_REGISTER, @@ -36,6 +38,7 @@ from .const import ( CALL_TYPE_X_REGISTER_HOLDINGS, CONF_DATA_TYPE, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_PRECISION, CONF_SCALE, CONF_STATE_OFF, @@ -76,6 +79,8 @@ class BasePlatform(Entity): self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = True self._attr_unit_of_measurement = None + self._lazy_error_count = entry[CONF_LAZY_ERROR] + self._lazy_errors = self._lazy_error_count @abstractmethod async def async_update(self, now=None): @@ -84,9 +89,10 @@ class BasePlatform(Entity): async def async_base_added_to_hass(self): """Handle entity which will be added.""" if self._scan_interval > 0: - async_track_time_interval( + cancel_func = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) ) + self._hub.entity_timers.append(cancel_func) class BaseStructPlatform(BasePlatform, RestoreEntity): @@ -105,7 +111,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): def _swap_registers(self, registers): """Do swap as needed.""" - if self._swap in [CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE]: + if self._swap in (CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE): # convert [12][34] --> [21][43] for i, register in enumerate(registers): registers[i] = int.from_bytes( @@ -113,7 +119,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): byteorder="big", signed=False, ) - if self._swap in [CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE]: + if self._swap in (CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE): # convert [12][34] ==> [34][12] registers.reverse() return registers @@ -124,53 +130,59 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers = self._swap_registers(registers) byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) if self._data_type == DATA_TYPE_STRING: - self._value = byte_string.decode() - else: - val = struct.unpack(self._structure, byte_string) + return byte_string.decode() - # Issue: https://github.com/home-assistant/core/issues/41944 - # If unpack() returns a tuple greater than 1, don't try to process the value. - # Instead, return the values of unpack(...) separated by commas. - if len(val) > 1: - # Apply scale and precision to floats and ints - v_result = [] - for entry in val: - v_temp = self._scale * entry + self._offset - - # We could convert int to float, and the code would still work; however - # we lose some precision, and unit tests will fail. Therefore, we do - # the conversion only when it's absolutely necessary. - if isinstance(v_temp, int) and self._precision == 0: - v_result.append(str(v_temp)) - else: - v_result.append(f"{float(v_temp):.{self._precision}f}") - self._value = ",".join(map(str, v_result)) - else: - # Apply scale and precision to floats and ints - val = self._scale * val[0] + self._offset + val = struct.unpack(self._structure, byte_string) + # Issue: https://github.com/home-assistant/core/issues/41944 + # If unpack() returns a tuple greater than 1, don't try to process the value. + # Instead, return the values of unpack(...) separated by commas. + if len(val) > 1: + # Apply scale and precision to floats and ints + v_result = [] + for entry in val: + v_temp = self._scale * entry + self._offset # We could convert int to float, and the code would still work; however # we lose some precision, and unit tests will fail. Therefore, we do # the conversion only when it's absolutely necessary. - if isinstance(val, int) and self._precision == 0: - self._value = str(val) + if isinstance(v_temp, int) and self._precision == 0: + v_result.append(str(v_temp)) else: - self._value = f"{float(val):.{self._precision}f}" + v_result.append(f"{float(v_temp):.{self._precision}f}") + return ",".join(map(str, v_result)) + + # Apply scale and precision to floats and ints + val = self._scale * val[0] + self._offset + + # We could convert int to float, and the code would still work; however + # we lose some precision, and unit tests will fail. Therefore, we do + # the conversion only when it's absolutely necessary. + if isinstance(val, int) and self._precision == 0: + return str(val) + return f"{float(val):.{self._precision}f}" -class BaseSwitch(BasePlatform, RestoreEntity): +class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Base class representing a Modbus switch.""" def __init__(self, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" config[CONF_INPUT_TYPE] = "" super().__init__(hub, config) - self._is_on = None + self._attr_is_on = False convert = { CALL_TYPE_REGISTER_HOLDING: ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_WRITE_REGISTER, ), + CALL_TYPE_DISCRETE: ( + CALL_TYPE_DISCRETE, + None, + ), + CALL_TYPE_REGISTER_INPUT: ( + CALL_TYPE_REGISTER_INPUT, + None, + ), CALL_TYPE_COIL: (CALL_TYPE_COIL, CALL_TYPE_WRITE_COIL), CALL_TYPE_X_COILS: (CALL_TYPE_COIL, CALL_TYPE_WRITE_COILS), CALL_TYPE_X_REGISTER_HOLDINGS: ( @@ -202,12 +214,7 @@ class BaseSwitch(BasePlatform, RestoreEntity): await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: - self._is_on = state.state == STATE_ON - - @property - def is_on(self): - """Return true if switch is on.""" - return self._is_on + self._attr_is_on = state.state == STATE_ON async def async_turn(self, command): """Evaluate switch result.""" @@ -221,7 +228,7 @@ class BaseSwitch(BasePlatform, RestoreEntity): self._attr_available = True if not self._verify_active: - self._is_on = command == self.command_on + self._attr_is_on = command == self.command_on self.async_write_ha_state() return @@ -252,19 +259,24 @@ class BaseSwitch(BasePlatform, RestoreEntity): ) self._call_active = False if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return + self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return + self._lazy_errors = self._lazy_error_count self._attr_available = True if self._verify_type == CALL_TYPE_COIL: - self._is_on = bool(result.bits[0] & 1) + self._attr_is_on = bool(result.bits[0] & 1) else: value = int(result.registers[0]) if value == self._state_on: - self._is_on = True + self._attr_is_on = True elif value == self._state_off: - self._is_on = False + self._attr_is_on = False elif value is not None: _LOGGER.error( "Unexpected response from modbus device slave %s register %s, got 0x%2x", diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index ac635c76275..adc5e2d28f1 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import get_hub from .base_platform import BasePlatform -from .const import MODBUS_DOMAIN PARALLEL_UPDATES = 1 _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ async def async_setup_platform( return for entry in discovery_info[CONF_BINARY_SENSORS]: - hub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub = get_hub(hass, discovery_info[CONF_NAME]) sensors.append(ModbusBinarySensor(hub, entry)) async_add_entities(sensors) @@ -43,14 +43,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: - self._value = state.state == STATE_ON - else: - self._value = None - - @property - def is_on(self): - """Return the state of the sensor.""" - return self._value + self._attr_is_on = state.state == STATE_ON async def async_update(self, now=None): """Update the state of the sensor.""" @@ -64,10 +57,15 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): ) self._call_active = False if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return + self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._value = result.bits[0] & 1 + self._lazy_errors = self._lazy_error_count + self._attr_is_on = result.bits[0] & 1 self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 1353828b926..0a89610a2f5 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -23,6 +23,7 @@ from homeassistant.core import HomeAssistant 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 ( ATTR_TEMPERATURE, @@ -39,7 +40,6 @@ from .const import ( DATA_TYPE_UINT16, DATA_TYPE_UINT32, DATA_TYPE_UINT64, - MODBUS_DOMAIN, ) from .modbus import ModbusHub @@ -59,7 +59,7 @@ async def async_setup_platform( entities = [] for entity in discovery_info[CONF_CLIMATES]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) entities.append(ModbusThermostat(hub, entity)) async_add_entities(entities) @@ -114,14 +114,14 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): target_temperature = ( float(kwargs.get(ATTR_TEMPERATURE)) - self._offset ) / self._scale - if self._data_type in [ + if self._data_type in ( DATA_TYPE_INT16, DATA_TYPE_INT32, DATA_TYPE_INT64, DATA_TYPE_UINT16, DATA_TYPE_UINT32, DATA_TYPE_UINT64, - ]: + ): target_temperature = int(target_temperature) as_bytes = struct.pack(self._structure, target_temperature) raw_regs = [ @@ -162,11 +162,15 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._slave, register, self._count, register_type ) if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return -1 + self._lazy_errors = self._lazy_error_count self._attr_available = False return -1 - self.unpack_structure_result(result.registers) - + self._lazy_errors = self._lazy_error_count + self._value = self.unpack_structure_result(result.registers) self._attr_available = True if self._value is None: diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index c5d182cdf4a..b259b93285f 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -1,5 +1,4 @@ """Constants used in modbus integration.""" - from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate.const import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN @@ -29,6 +28,7 @@ CONF_FANS = "fans" CONF_HUB = "hub" CONF_INPUTS = "inputs" CONF_INPUT_TYPE = "input_type" +CONF_LAZY_ERROR = "lazy_error_count" CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" CONF_MSG_WAIT = "message_wait_milliseconds" @@ -40,9 +40,8 @@ CONF_RETRIES = "retries" CONF_RETRY_ON_EMPTY = "retry_on_empty" CONF_REVERSE_ORDER = "reverse_order" CONF_PRECISION = "precision" -CONF_RTUOVERTCP = "rtuovertcp" CONF_SCALE = "scale" -CONF_SERIAL = "serial" +CONF_STATE_CLASS = "state_class" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OFF = "state_off" @@ -59,13 +58,16 @@ CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" -CONF_TCP = "tcp" -CONF_UDP = "udp" CONF_VERIFY = "verify" CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" CONF_WRITE_TYPE = "write_type" +RTUOVERTCP = "rtuovertcp" +SERIAL = "serial" +TCP = "tcp" +UDP = "udp" + # service call attributes ATTR_ADDRESS = "address" ATTR_HUB = "hub" @@ -111,18 +113,8 @@ DEFAULT_HUB = "modbus_hub" DEFAULT_SCAN_INTERVAL = 15 # seconds DEFAULT_SLAVE = 1 DEFAULT_STRUCTURE_PREFIX = ">f" -DEFAULT_STRUCT_FORMAT = { - DATA_TYPE_INT16: ["h", 1], - DATA_TYPE_INT32: ["i", 2], - DATA_TYPE_INT64: ["q", 4], - DATA_TYPE_UINT16: ["H", 1], - DATA_TYPE_UINT32: ["I", 2], - DATA_TYPE_UINT64: ["Q", 4], - DATA_TYPE_FLOAT16: ["e", 1], - DATA_TYPE_FLOAT32: ["f", 2], - DATA_TYPE_FLOAT64: ["d", 4], - DATA_TYPE_STRING: ["s", 1], -} + + DEFAULT_TEMP_UNIT = "C" MODBUS_DOMAIN = "modbus" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 98a352f218a..5fa77eb1cb8 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import get_hub from .base_platform import BasePlatform from .const import ( CALL_TYPE_COIL, @@ -30,7 +31,6 @@ from .const import ( CONF_STATE_OPENING, CONF_STATUS_REGISTER, CONF_STATUS_REGISTER_TYPE, - MODBUS_DOMAIN, ) from .modbus import ModbusHub @@ -50,7 +50,7 @@ async def async_setup_platform( covers = [] for cover in discovery_info[CONF_COVERS]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) covers.append(ModbusCover(hub, cover)) async_add_entities(covers) @@ -109,22 +109,13 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): STATE_UNAVAILABLE: None, STATE_UNKNOWN: None, } - self._value = convert[state.state] + self._set_attr_state(convert[state.state]) - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._value == self._state_opening - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self._value == self._state_closing - - @property - def is_closed(self): - """Return if the cover is closed or not.""" - return self._value == self._state_closed + def _set_attr_state(self, value): + """Convert received value to HA state.""" + self._attr_is_opening = value == self._state_opening + self._attr_is_closing = value == self._state_closing + self._attr_is_closed = value == self._state_closed async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" @@ -155,12 +146,17 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): ) self._call_active = False if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return + self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() - return None + return + self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type == CALL_TYPE_COIL: - self._value = bool(result.bits[0] & 1) + self._set_attr_state(bool(result.bits[0] & 1)) else: - self._value = int(result.registers[0]) + self._set_attr_state(int(result.registers[0])) self.async_write_ha_state() diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index a4d4265846d..cf5c9762db8 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -8,8 +8,9 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import get_hub from .base_platform import BaseSwitch -from .const import CONF_FANS, MODBUS_DOMAIN +from .const import CONF_FANS from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -25,7 +26,7 @@ async def async_setup_platform( fans = [] for entry in discovery_info[CONF_FANS]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) fans.append(ModbusFan(hub, entry)) async_add_entities(fans) @@ -42,3 +43,11 @@ class ModbusFan(BaseSwitch, FanEntity): ) -> None: """Set fan on.""" await self.async_turn(self.command_on) + + @property + def is_on(self): + """Return true if fan is on. + + This is needed due to the ongoing conversion of fan. + """ + return self._attr_is_on diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 3eae5ed3db3..dd9a8ad754d 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -8,8 +8,8 @@ from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import get_hub from .base_platform import BaseSwitch -from .const import MODBUS_DOMAIN from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -25,7 +25,7 @@ async def async_setup_platform( lights = [] for entry in discovery_info[CONF_LIGHTS]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) lights.append(ModbusLight(hub, entry)) async_add_entities(lights) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 9f2208de175..ceade8c6455 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -2,8 +2,8 @@ "domain": "modbus", "name": "Modbus", "documentation": "https://www.home-assistant.io/integrations/modbus", - "requirements": ["pymodbus==2.5.2"], + "requirements": ["pymodbus==2.5.3rc1"], "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"], - "quality_scale": "silver", + "quality_scale": "gold", "iot_class": "local_polling" } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 1cb68aa4fd6..4889b27faf0 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -1,6 +1,8 @@ """Support for Modbus.""" +from __future__ import annotations + import asyncio -from copy import deepcopy +from collections import namedtuple import logging from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient @@ -18,7 +20,7 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.event import async_call_later @@ -43,66 +45,65 @@ from .const import ( CONF_PARITY, CONF_RETRIES, CONF_RETRY_ON_EMPTY, - CONF_RTUOVERTCP, - CONF_SERIAL, CONF_STOPBITS, - CONF_TCP, - CONF_UDP, DEFAULT_HUB, MODBUS_DOMAIN as DOMAIN, PLATFORMS, + RTUOVERTCP, + SERIAL, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, + TCP, + UDP, ) -ENTRY_FUNC = "func" -ENTRY_ATTR = "attr" -ENTRY_NAME = "name" - _LOGGER = logging.getLogger(__name__) -PYMODBUS_CALL = { - CALL_TYPE_COIL: { - ENTRY_ATTR: "bits", - ENTRY_NAME: "read_coils", - ENTRY_FUNC: None, - }, - CALL_TYPE_DISCRETE: { - ENTRY_ATTR: "bits", - ENTRY_NAME: "read_discrete_inputs", - ENTRY_FUNC: None, - }, - CALL_TYPE_REGISTER_HOLDING: { - ENTRY_ATTR: "registers", - ENTRY_NAME: "read_holding_registers", - ENTRY_FUNC: None, - }, - CALL_TYPE_REGISTER_INPUT: { - ENTRY_ATTR: "registers", - ENTRY_NAME: "read_input_registers", - ENTRY_FUNC: None, - }, - CALL_TYPE_WRITE_COIL: { - ENTRY_ATTR: "value", - ENTRY_NAME: "write_coil", - ENTRY_FUNC: None, - }, - CALL_TYPE_WRITE_COILS: { - ENTRY_ATTR: "count", - ENTRY_NAME: "write_coils", - ENTRY_FUNC: None, - }, - CALL_TYPE_WRITE_REGISTER: { - ENTRY_ATTR: "value", - ENTRY_NAME: "write_register", - ENTRY_FUNC: None, - }, - CALL_TYPE_WRITE_REGISTERS: { - ENTRY_ATTR: "count", - ENTRY_NAME: "write_registers", - ENTRY_FUNC: None, - }, -} + +ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") +RunEntry = namedtuple("RunEntry", "attr func") +PYMODBUS_CALL = [ + ConfEntry( + CALL_TYPE_COIL, + "bits", + "read_coils", + ), + ConfEntry( + CALL_TYPE_DISCRETE, + "bits", + "read_discrete_inputs", + ), + ConfEntry( + CALL_TYPE_REGISTER_HOLDING, + "registers", + "read_holding_registers", + ), + ConfEntry( + CALL_TYPE_REGISTER_INPUT, + "registers", + "read_input_registers", + ), + ConfEntry( + CALL_TYPE_WRITE_COIL, + "value", + "write_coil", + ), + ConfEntry( + CALL_TYPE_WRITE_COILS, + "count", + "write_coils", + ), + ConfEntry( + CALL_TYPE_WRITE_REGISTER, + "value", + "write_register", + ), + ConfEntry( + CALL_TYPE_WRITE_REGISTERS, + "count", + "write_registers", + ), +] async def async_modbus_setup( @@ -186,6 +187,10 @@ async def async_modbus_setup( class ModbusHub: """Thread safe wrapper class for pymodbus.""" + name: str + + entity_timers: list[CALLBACK_TYPE] = [] + def __init__(self, hass, client_config): """Initialize the Modbus hub.""" @@ -195,15 +200,15 @@ class ModbusHub: self._in_error = False self._lock = asyncio.Lock() self.hass = hass - self._config_name = client_config[CONF_NAME] + self.name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] self._config_delay = client_config[CONF_DELAY] - self._pb_call = deepcopy(PYMODBUS_CALL) + self._pb_call = {} self._pb_class = { - CONF_SERIAL: ModbusSerialClient, - CONF_TCP: ModbusTcpClient, - CONF_UDP: ModbusUdpClient, - CONF_RTUOVERTCP: ModbusTcpClient, + SERIAL: ModbusSerialClient, + TCP: ModbusTcpClient, + UDP: ModbusUdpClient, + RTUOVERTCP: ModbusTcpClient, } self._pb_params = { "port": client_config[CONF_PORT], @@ -212,7 +217,7 @@ class ModbusHub: "retries": client_config[CONF_RETRIES], "retry_on_empty": client_config[CONF_RETRY_ON_EMPTY], } - if self._config_type == CONF_SERIAL: + if self._config_type == SERIAL: # serial configuration self._pb_params.update( { @@ -226,19 +231,19 @@ class ModbusHub: else: # network configuration self._pb_params["host"] = client_config[CONF_HOST] - if self._config_type == CONF_RTUOVERTCP: + if self._config_type == RTUOVERTCP: self._pb_params["framer"] = ModbusRtuFramer Defaults.Timeout = client_config[CONF_TIMEOUT] if CONF_MSG_WAIT in client_config: self._msg_wait = client_config[CONF_MSG_WAIT] / 1000 - elif self._config_type == CONF_SERIAL: + elif self._config_type == SERIAL: self._msg_wait = 30 / 1000 else: self._msg_wait = 0 def _log_error(self, text: str, error_state=True): - log_text = f"Pymodbus: {text}" + log_text = f"Pymodbus: {self.name}: {text}" if self._in_error: _LOGGER.debug(log_text) else: @@ -253,8 +258,9 @@ class ModbusHub: self._log_error(str(exception_error), error_state=False) return False - for entry in self._pb_call.values(): - entry[ENTRY_FUNC] = getattr(self._client, entry[ENTRY_NAME]) + for entry in PYMODBUS_CALL: + func = getattr(self._client, entry.func_name) + self._pb_call[entry.call_type] = RunEntry(entry.attr, func) await self.async_connect_task() return True @@ -263,7 +269,7 @@ class ModbusHub: """Try to connect, and retry if needed.""" async with self._lock: if not await self.hass.async_add_executor_job(self._pymodbus_connect): - err = f"{self._config_name} connect failed, retry in pymodbus" + err = f"{self.name} connect failed, retry in pymodbus" self._log_error(err, error_state=False) return @@ -279,8 +285,14 @@ class ModbusHub: self._async_cancel_listener = None self._config_delay = 0 - def _pymodbus_close(self): - """Close sync. pymodbus.""" + async def async_close(self): + """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() @@ -288,15 +300,6 @@ class ModbusHub: self._log_error(str(exception_error)) self._client = None - async def async_close(self): - """Disconnect client.""" - if self._async_cancel_listener: - self._async_cancel_listener() - self._async_cancel_listener = None - - async with self._lock: - return await self.hass.async_add_executor_job(self._pymodbus_close) - def _pymodbus_connect(self): """Connect client.""" try: @@ -308,12 +311,13 @@ class ModbusHub: def _pymodbus_call(self, unit, address, value, use_call): """Call sync. pymodbus.""" kwargs = {"unit": unit} if unit else {} + entry = self._pb_call[use_call] try: - result = self._pb_call[use_call][ENTRY_FUNC](address, value, **kwargs) + result = entry.func(address, value, **kwargs) except ModbusException as exception_error: self._log_error(str(exception_error)) return None - if not hasattr(result, self._pb_call[use_call][ENTRY_ATTR]): + if not hasattr(result, entry.attr): self._log_error(str(result)) return None self._in_error = False diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index e969fa23a65..c2f69065196 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -10,8 +10,9 @@ from homeassistant.core import HomeAssistant 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 MODBUS_DOMAIN +from .const import CONF_STATE_CLASS from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -31,7 +32,7 @@ async def async_setup_platform( return for entry in discovery_info[CONF_SENSORS]: - hub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub = get_hub(hass, discovery_info[CONF_NAME]) sensors.append(ModbusRegisterSensor(hub, entry)) async_add_entities(sensors) @@ -47,19 +48,15 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): ) -> None: """Initialize the modbus register sensor.""" super().__init__(hub, entry) - self._attr_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) + 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): """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state: - self._value = state.state - - @property - def state(self): - """Return the state of the sensor.""" - return self._value + self._attr_native_value = state.state async def async_update(self, now=None): """Update the state of the sensor.""" @@ -69,10 +66,15 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): self._slave, self._address, self._count, self._input_type ) if result is None: + if self._lazy_errors: + self._lazy_errors -= 1 + return + self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self.unpack_structure_result(result.registers) + self._attr_native_value = self.unpack_structure_result(result.registers) + self._lazy_errors = self._lazy_error_count self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 820e43419a0..55dc014420f 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -8,8 +8,8 @@ from homeassistant.const import CONF_NAME, CONF_SWITCHES from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import get_hub from .base_platform import BaseSwitch -from .const import MODBUS_DOMAIN from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -26,7 +26,7 @@ async def async_setup_platform( return for entry in discovery_info[CONF_SWITCHES]: - hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) switches.append(ModbusSwitch(hub, entry)) async_add_entities(switches) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index f7fdae0a82a..fdfffaebd61 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -1,6 +1,7 @@ """Validate Modbus configuration.""" from __future__ import annotations +from collections import namedtuple import logging import struct from typing import Any @@ -8,11 +9,16 @@ from typing import Any import voluptuous as vol from homeassistant.const import ( + CONF_ADDRESS, CONF_COUNT, + CONF_HOST, CONF_NAME, + CONF_PORT, CONF_SCAN_INTERVAL, + CONF_SLAVE, CONF_STRUCTURE, CONF_TIMEOUT, + CONF_TYPE, ) from .const import ( @@ -29,13 +35,15 @@ from .const import ( DATA_TYPE_INT16, DATA_TYPE_INT32, DATA_TYPE_INT64, + DATA_TYPE_STRING, DATA_TYPE_UINT, DATA_TYPE_UINT16, DATA_TYPE_UINT32, DATA_TYPE_UINT64, + DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, - DEFAULT_STRUCT_FORMAT, PLATFORMS, + SERIAL, ) _LOGGER = logging.getLogger(__name__) @@ -57,6 +65,19 @@ OLD_DATA_TYPES = { 4: DATA_TYPE_FLOAT64, }, } +ENTRY = namedtuple("ENTRY", ["struct_id", "register_count"]) +DEFAULT_STRUCT_FORMAT = { + DATA_TYPE_INT16: ENTRY("h", 1), + DATA_TYPE_INT32: ENTRY("i", 2), + DATA_TYPE_INT64: ENTRY("q", 4), + DATA_TYPE_UINT16: ENTRY("H", 1), + DATA_TYPE_UINT32: ENTRY("I", 2), + DATA_TYPE_UINT64: ENTRY("Q", 4), + DATA_TYPE_FLOAT16: ENTRY("e", 1), + DATA_TYPE_FLOAT32: ENTRY("f", 2), + DATA_TYPE_FLOAT64: ENTRY("d", 4), + DATA_TYPE_STRING: ENTRY("s", 1), +} def struct_validator(config): @@ -67,7 +88,7 @@ def struct_validator(config): name = config[CONF_NAME] structure = config.get(CONF_STRUCTURE) swap_type = config.get(CONF_SWAP) - if data_type in [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]: + if data_type in (DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT): error = f"{name} with {data_type} is not valid, trying to convert" _LOGGER.warning(error) try: @@ -80,9 +101,9 @@ def struct_validator(config): if structure: error = f"{name} structure: cannot be mixed with {data_type}" raise vol.Invalid(error) - structure = f">{DEFAULT_STRUCT_FORMAT[data_type][0]}" + structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" if CONF_COUNT not in config: - config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type][1] + config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count else: if not structure: error = ( @@ -175,3 +196,59 @@ def scan_interval_validator(config: dict) -> dict: ) hub[CONF_TIMEOUT] = minimum_scan_interval - 1 return config + + +def duplicate_entity_validator(config: dict) -> dict: + """Control scan_interval.""" + for hub_index, hub in enumerate(config): + addresses: set[str] = set() + for component, conf_key in PLATFORMS: + if conf_key not in hub: + continue + names: set[str] = set() + errors: list[int] = [] + for index, entry in enumerate(hub[conf_key]): + name = entry[CONF_NAME] + addr = str(entry[CONF_ADDRESS]) + if CONF_SLAVE in entry: + addr += "_" + str(entry[CONF_SLAVE]) + if addr in addresses: + err = f"Modbus {component}/{name} address {addr} is duplicate, second entry not loaded!" + _LOGGER.warning(err) + errors.append(index) + elif name in names: + err = f"Modbus {component}/{name}  is duplicate, second entry not loaded!" + _LOGGER.warning(err) + errors.append(index) + else: + names.add(name) + addresses.add(addr) + + for i in reversed(errors): + del config[hub_index][conf_key][i] + return config + + +def duplicate_modbus_validator(config: list) -> list: + """Control modbus connection for duplicates.""" + hosts: set[str] = set() + names: set[str] = set() + errors = [] + for index, hub in enumerate(config): + name = hub.get(CONF_NAME, DEFAULT_HUB) + host = hub[CONF_PORT] if hub[CONF_TYPE] == SERIAL else hub[CONF_HOST] + if host in hosts: + err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" + _LOGGER.warning(err) + errors.append(index) + elif name in names: + err = f"Modbus {name}  is duplicate, second entry not loaded!" + _LOGGER.warning(err) + errors.append(index) + else: + hosts.add(host) + names.add(name) + + for i in reversed(errors): + del config[i] + return config diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 080e077a457..afbc09eb45c 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -75,7 +75,7 @@ class ModemCalleridSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index a31b2655184..09ca43797af 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -17,7 +17,14 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODEL, ATTR_NAME, ATTR_SW_VERSION, CONF_HOST +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_HOST, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo @@ -27,7 +34,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, DOMAIN +from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=5) PLATFORMS = [ @@ -153,7 +160,7 @@ class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator def device_info(self) -> DeviceInfo: """Return device information about this Modern Forms device.""" return { - ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.mac_address)}, # type: ignore + ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.mac_address)}, ATTR_NAME: self.coordinator.data.info.device_name, ATTR_MANUFACTURER: "Modern Forms", ATTR_MODEL: self.coordinator.data.info.fan_type, diff --git a/homeassistant/components/modern_forms/const.py b/homeassistant/components/modern_forms/const.py index 9dbefcfc570..b96cf57351c 100644 --- a/homeassistant/components/modern_forms/const.py +++ b/homeassistant/components/modern_forms/const.py @@ -2,9 +2,6 @@ DOMAIN = "modern_forms" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" - OPT_ON = "on" OPT_SPEED = "speed" OPT_BRIGHTNESS = "brightness" diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 01efe3f1d28..1e51ec9a1ae 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -73,7 +73,7 @@ class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor): self._attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" sleep_time: datetime = dt_util.utc_from_timestamp( self.coordinator.data.state.light_sleep_timer @@ -103,7 +103,7 @@ class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor): self._attr_device_class = DEVICE_CLASS_TIMESTAMP @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" sleep_time: datetime = dt_util.utc_from_timestamp( self.coordinator.data.state.fan_sleep_timer diff --git a/homeassistant/components/modern_forms/translations/ca.json b/homeassistant/components/modern_forms/translations/ca.json index cea3bc7b685..e7a70e80b93 100644 --- a/homeassistant/components/modern_forms/translations/ca.json +++ b/homeassistant/components/modern_forms/translations/ca.json @@ -16,7 +16,7 @@ "data": { "host": "Amfitri\u00f3" }, - "description": "Configura el teu ventilador Modern Forms per integrar-lo a Home Assistant." + "description": "Configura la integraci\u00f3 d'un ventilador Modern Forms amb Home Assistant." }, "zeroconf_confirm": { "description": "Vols afegir el ventilador de Modern Forms anomenat `{name}` a Home Assistant?", diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 7bfa161f9ec..c57903ce5b7 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -359,12 +359,12 @@ class MoldIndicator(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self._state diff --git a/homeassistant/components/monoprice/translations/hu.json b/homeassistant/components/monoprice/translations/hu.json index a845f862160..fd11a8fbc0f 100644 --- a/homeassistant/components/monoprice/translations/hu.json +++ b/homeassistant/components/monoprice/translations/hu.json @@ -10,8 +10,30 @@ "step": { "user": { "data": { - "port": "Port" - } + "port": "Port", + "source_1": "Forr\u00e1s neve #1", + "source_2": "Forr\u00e1s neve #2", + "source_3": "Forr\u00e1s neve #3", + "source_4": "Forr\u00e1s neve #4", + "source_5": "Forr\u00e1s neve #5", + "source_6": "Forr\u00e1s neve #6" + }, + "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "source_1": "Forr\u00e1s neve #1", + "source_2": "Forr\u00e1s neve #2", + "source_3": "Forr\u00e1s neve #3", + "source_4": "Forr\u00e1s neve #4", + "source_5": "Forr\u00e1s neve #5", + "source_6": "Forr\u00e1s neve #6" + }, + "title": "Forr\u00e1sok konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 6213e218d24..223ee831779 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -60,7 +60,7 @@ class MoonSensor(SensorEntity): return "moon__phase" @property - def state(self): + def native_value(self): """Return the state of the device.""" if self._state == 0: return STATE_NEW_MOON diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index be88a099f25..9c6db5d88ec 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -47,7 +47,7 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): """ _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" @@ -70,7 +70,7 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._blind.battery_level @@ -106,7 +106,7 @@ class MotionTDBUBatterySensor(MotionBatterySensor): self._attr_name = f"{blind.blind_type}-{motor}-battery-{blind.mac[12:]}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._blind.battery_level is None: return None @@ -128,7 +128,7 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH _attr_entity_registry_enabled_default = False - _attr_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT def __init__(self, coordinator, device, device_type): """Initialize the Motion Signal Strength Sensor.""" @@ -162,7 +162,7 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.RSSI diff --git a/homeassistant/components/motion_blinds/translations/hu.json b/homeassistant/components/motion_blinds/translations/hu.json index 19f0c70c4d6..a2560e5fa79 100644 --- a/homeassistant/components/motion_blinds/translations/hu.json +++ b/homeassistant/components/motion_blinds/translations/hu.json @@ -8,24 +8,29 @@ "error": { "discovery_error": "Nem siker\u00fclt felfedezni a Motion Gateway-t" }, + "flow_title": "Mozg\u00f3 red\u0151ny", "step": { "connect": { "data": { "api_key": "API kulcs" }, - "description": "Sz\u00fcks\u00e9ge lesz a 16 karakteres API kulcsra, \u00fatmutat\u00e1s\u00e9rt l\u00e1sd: https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key" + "description": "Sz\u00fcks\u00e9ge lesz a 16 karakteres API kulcsra, \u00fatmutat\u00e1s\u00e9rt l\u00e1sd: https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key", + "title": "Mozg\u00f3 red\u0151ny" }, "select": { "data": { "select_ip": "IP c\u00edm" - } + }, + "description": "Futtassa \u00fajra a be\u00e1ll\u00edt\u00e1st, ha tov\u00e1bbi Motion Gateway-eket szeretne csatlakoztatni", + "title": "V\u00e1lassza ki a csatlakoztatni k\u00edv\u00e1nt Motion Gateway-t" }, "user": { "data": { "api_key": "API kulcs", "host": "IP c\u00edm" }, - "description": "Csatlakozzon a Motion Gateway-hez, ha az IP-c\u00edm nincs be\u00e1ll\u00edtva, akkor az automatikus felder\u00edt\u00e9st haszn\u00e1lja" + "description": "Csatlakozzon a Motion Gateway-hez, ha az IP-c\u00edm nincs be\u00e1ll\u00edtva, akkor az automatikus felder\u00edt\u00e9st haszn\u00e1lja", + "title": "Mozg\u00f3 red\u0151ny" } } } diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index acafdceeb05..3eebcd4ee53 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -237,8 +237,8 @@ def _add_camera( if entry.options.get(CONF_WEBHOOK_SET, DEFAULT_WEBHOOK_SET): url = async_generate_motioneye_webhook(hass, entry.data[CONF_WEBHOOK_ID]) - if url and ( - _set_webhook( + if url: + set_motion_event = _set_webhook( _build_url( device, url, @@ -250,7 +250,8 @@ def _add_camera( KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, camera, ) - | _set_webhook( + + set_storage_event = _set_webhook( _build_url( device, url, @@ -262,8 +263,8 @@ def _add_camera( KEY_WEB_HOOK_STORAGE_ENABLED, camera, ) - ): - hass.async_create_task(client.async_set_camera(camera_id, camera)) + if set_motion_event or set_storage_event: + hass.async_create_task(client.async_set_camera(camera_id, camera)) async_dispatcher_send( hass, diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 0727646b64d..e7ff75812f6 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -126,10 +126,10 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): ) -> dict[str, Any]: """Convert a motionEye camera to MjpegCamera internal properties.""" auth = None - if camera.get(KEY_STREAMING_AUTH_MODE) in [ + if camera.get(KEY_STREAMING_AUTH_MODE) in ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, - ]: + ): auth = camera[KEY_STREAMING_AUTH_MODE] return { diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index f9197d00c08..abe4314447c 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -15,10 +15,9 @@ from motioneye_client.const import ( KEY_VIDEO_STREAMING, ) -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -26,26 +25,26 @@ from . import MotionEyeEntity, get_camera_from_cameras, listen_for_new_cameras from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE MOTIONEYE_SWITCHES = [ - EntityDescription( + SwitchEntityDescription( key=KEY_MOTION_DETECTION, name="Motion Detection", entity_registry_enabled_default=True, ), - EntityDescription( + SwitchEntityDescription( key=KEY_TEXT_OVERLAY, name="Text Overlay", entity_registry_enabled_default=False ), - EntityDescription( + SwitchEntityDescription( key=KEY_VIDEO_STREAMING, name="Video Streaming", entity_registry_enabled_default=False, ), - EntityDescription( + SwitchEntityDescription( key=KEY_STILL_IMAGES, name="Still Images", entity_registry_enabled_default=True ), - EntityDescription( + SwitchEntityDescription( key=KEY_MOVIES, name="Movies", entity_registry_enabled_default=True ), - EntityDescription( + SwitchEntityDescription( key=KEY_UPLOAD_ENABLED, name="Upload Enabled", entity_registry_enabled_default=False, @@ -89,7 +88,7 @@ class MotionEyeSwitch(MotionEyeEntity, SwitchEntity): client: MotionEyeClient, coordinator: DataUpdateCoordinator, options: MappingProxyType[str, str], - entity_description: EntityDescription, + entity_description: SwitchEntityDescription, ) -> None: """Initialize the switch.""" super().__init__( diff --git a/homeassistant/components/motioneye/translations/en_GB.json b/homeassistant/components/motioneye/translations/en_GB.json new file mode 100644 index 00000000000..d197c3f9026 --- /dev/null +++ b/homeassistant/components/motioneye/translations/en_GB.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "webhook_set_overwrite": "Overwrite unrecognised webhooks" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 6bb7a92e8af..dd2f631848e 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -113,6 +113,7 @@ ABBREVIATIONS = { "pl_arm_away": "payload_arm_away", "pl_arm_home": "payload_arm_home", "pl_arm_nite": "payload_arm_night", + "pl_arm_vacation": "payload_arm_vacation", "pl_arm_custom_b": "payload_arm_custom_bypass", "pl_avail": "payload_available", "pl_cln_sp": "payload_clean_spot", diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index aa98a48dc10..f3e8e112f1a 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -11,6 +11,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_CUSTOM_BYPASS, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, ) from homeassistant.const import ( CONF_CODE, @@ -20,6 +21,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_DISARMING, @@ -52,6 +54,7 @@ CONF_PAYLOAD_DISARM = "payload_disarm" CONF_PAYLOAD_ARM_HOME = "payload_arm_home" CONF_PAYLOAD_ARM_AWAY = "payload_arm_away" CONF_PAYLOAD_ARM_NIGHT = "payload_arm_night" +CONF_PAYLOAD_ARM_VACATION = "payload_arm_vacation" CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass" CONF_COMMAND_TEMPLATE = "command_template" @@ -65,6 +68,7 @@ MQTT_ALARM_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_COMMAND_TEMPLATE = "{{action}}" DEFAULT_ARM_NIGHT = "ARM_NIGHT" +DEFAULT_ARM_VACATION = "ARM_VACATION" DEFAULT_ARM_AWAY = "ARM_AWAY" DEFAULT_ARM_HOME = "ARM_HOME" DEFAULT_ARM_CUSTOM_BYPASS = "ARM_CUSTOM_BYPASS" @@ -83,6 +87,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, + vol.Optional( + CONF_PAYLOAD_ARM_VACATION, default=DEFAULT_ARM_VACATION + ): cv.string, vol.Optional( CONF_PAYLOAD_ARM_CUSTOM_BYPASS, default=DEFAULT_ARM_CUSTOM_BYPASS ): cv.string, @@ -158,6 +165,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_PENDING, STATE_ALARM_ARMING, @@ -193,6 +201,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_ARM_VACATION | SUPPORT_ALARM_ARM_CUSTOM_BYPASS ) @@ -256,6 +265,17 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_NIGHT] self._publish(code, action) + async def async_alarm_arm_vacation(self, code=None): + """Send arm vacation command. + + This method is a coroutine. + """ + code_required = self._config[CONF_CODE_ARM_REQUIRED] + if code_required and not self._validate_code(code, "arming vacation"): + return + action = self._config[CONF_PAYLOAD_ARM_VACATION] + self._publish(code, action) + async def async_alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command. diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index adcb9ca623a..ebd6956e8fd 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -1,4 +1,6 @@ """Camera that loads a picture from an MQTT topic.""" +from __future__ import annotations + import functools import voluptuous as vol @@ -98,6 +100,8 @@ class MqttCamera(MqttEntity, Camera): }, ) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" return self._last_image diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index c6af0cc08b5..172657ded98 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -95,8 +95,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info): """Receive a Hass.io discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + await self._async_handle_discovery_without_unique_id() self._hassio_discovery = discovery_info diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 89246406de3..b4b586e14d2 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Callable +from typing import Any, Callable import attr import voluptuous as vol @@ -287,7 +287,9 @@ async def async_device_removed(hass: HomeAssistant, device_id: str): ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for MQTT devices.""" triggers: list[dict] = [] diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0659baa9144..6996072d226 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -291,7 +291,7 @@ async def async_start( # noqa: C901 result and result["type"] == RESULT_TYPE_ABORT and result["reason"] - in ["already_configured", "single_instance_allowed"] + in ("already_configured", "single_instance_allowed") ): unsub = hass.data[INTEGRATION_UNSUBSCRIBE].pop(key, None) if unsub is None: diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 552ee8da6d6..ee328c1eda5 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -10,7 +10,6 @@ from homeassistant.components.fan import ( ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, - ATTR_SPEED, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, @@ -19,7 +18,6 @@ from homeassistant.components.fan import ( SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, - speed_list_without_preset_modes, ) from homeassistant.const import ( CONF_NAME, @@ -34,8 +32,6 @@ from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( int_states_in_range, - ordered_list_item_to_percentage, - percentage_to_ordered_list_item, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -102,23 +98,12 @@ MQTT_FAN_ATTRIBUTES_BLOCKED = frozenset( fan.ATTR_PERCENTAGE, fan.ATTR_PRESET_MODE, fan.ATTR_PRESET_MODES, - fan.ATTR_SPEED_LIST, - fan.ATTR_SPEED, } ) _LOGGER = logging.getLogger(__name__) -def valid_fan_speed_configuration(config): - """Validate that the fan speed configuration is valid, throws if it isn't.""" - if config.get(CONF_SPEED_COMMAND_TOPIC) and not speed_list_without_preset_modes( - config.get(CONF_SPEED_LIST) - ): - raise ValueError("No valid speeds configured") - return config - - def valid_speed_range_configuration(config): """Validate that the fan speed_range configuration is valid, throws if it isn't.""" if config.get(CONF_SPEED_RANGE_MIN) == 0: @@ -138,7 +123,7 @@ def valid_preset_mode_configuration(config): PLATFORM_SCHEMA = vol.All( # CONF_SPEED_COMMAND_TOPIC, CONF_SPEED_LIST, CONF_SPEED_STATE_TOPIC, CONF_SPEED_VALUE_TEMPLATE and # Speeds SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH SPEED_OFF, - # are deprecated, support will be removed after a quarter (2021.7) + # are deprecated, support will be removed with release 2021.9 cv.deprecated(CONF_PAYLOAD_HIGH_SPEED), cv.deprecated(CONF_PAYLOAD_LOW_SPEED), cv.deprecated(CONF_PAYLOAD_MEDIUM_SPEED), @@ -203,7 +188,6 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), - valid_fan_speed_configuration, valid_speed_range_configuration, valid_preset_mode_configuration, ) @@ -240,8 +224,6 @@ class MqttFan(MqttEntity, FanEntity): def __init__(self, hass, config, config_entry, discovery_data): """Initialize the MQTT fan.""" self._state = False - # self._speed will be removed after a quarter (2021.7) - self._speed = None self._percentage = None self._preset_mode = None self._oscillation = None @@ -255,10 +237,6 @@ class MqttFan(MqttEntity, FanEntity): self._optimistic_oscillation = None self._optimistic_percentage = None self._optimistic_preset_mode = None - self._optimistic_speed = None - - self._legacy_speeds_list = [] - self._legacy_speeds_list_no_off = [] MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @@ -282,8 +260,6 @@ class MqttFan(MqttEntity, FanEntity): CONF_PERCENTAGE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, CONF_PRESET_MODE_COMMAND_TOPIC, - CONF_SPEED_STATE_TOPIC, - CONF_SPEED_COMMAND_TOPIC, CONF_OSCILLATION_STATE_TOPIC, CONF_OSCILLATION_COMMAND_TOPIC, ) @@ -292,8 +268,6 @@ class MqttFan(MqttEntity, FanEntity): CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_VALUE_TEMPLATE), ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_VALUE_TEMPLATE), - # ATTR_SPEED is deprecated in the schema, support will be removed after a quarter (2021.7) - ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE), ATTR_OSCILLATING: config.get(CONF_OSCILLATION_VALUE_TEMPLATE), } self._command_templates = { @@ -307,21 +281,9 @@ class MqttFan(MqttEntity, FanEntity): "STATE_OFF": config[CONF_PAYLOAD_OFF], "OSCILLATE_ON_PAYLOAD": config[CONF_PAYLOAD_OSCILLATION_ON], "OSCILLATE_OFF_PAYLOAD": config[CONF_PAYLOAD_OSCILLATION_OFF], - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - "SPEED_LOW": config[CONF_PAYLOAD_LOW_SPEED], - "SPEED_MEDIUM": config[CONF_PAYLOAD_MEDIUM_SPEED], - "SPEED_HIGH": config[CONF_PAYLOAD_HIGH_SPEED], - "SPEED_OFF": config[CONF_PAYLOAD_OFF_SPEED], "PERCENTAGE_RESET": config[CONF_PAYLOAD_RESET_PERCENTAGE], "PRESET_MODE_RESET": config[CONF_PAYLOAD_RESET_PRESET_MODE], } - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - self._feature_legacy_speeds = not self._topic[CONF_SPEED_COMMAND_TOPIC] is None - if self._feature_legacy_speeds: - self._legacy_speeds_list = config[CONF_SPEED_LIST] - self._legacy_speeds_list_no_off = speed_list_without_preset_modes( - self._legacy_speeds_list - ) self._feature_percentage = CONF_PERCENTAGE_COMMAND_TOPIC in config self._feature_preset_mode = CONF_PRESET_MODE_COMMAND_TOPIC in config @@ -330,10 +292,11 @@ class MqttFan(MqttEntity, FanEntity): else: self._preset_modes = [] - if self._feature_percentage: - self._speed_count = min(int_states_in_range(self._speed_range), 100) - else: - self._speed_count = len(self._legacy_speeds_list_no_off) or 100 + self._speed_count = ( + min(int_states_in_range(self._speed_range), 100) + if self._feature_percentage + else 100 + ) optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None @@ -346,16 +309,13 @@ class MqttFan(MqttEntity, FanEntity): self._optimistic_preset_mode = ( optimistic or self._topic[CONF_PRESET_MODE_STATE_TOPIC] is None ) - self._optimistic_speed = ( - optimistic or self._topic[CONF_SPEED_STATE_TOPIC] is None - ) self._supported_features = 0 self._supported_features |= ( self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None and SUPPORT_OSCILLATE ) - if self._feature_percentage or self._feature_legacy_speeds: + if self._feature_percentage: self._supported_features |= SUPPORT_SET_SPEED if self._feature_preset_mode: self._supported_features |= SUPPORT_PRESET_MODE @@ -368,7 +328,7 @@ class MqttFan(MqttEntity, FanEntity): tpl.hass = self.hass tpl_dict[key] = tpl.async_render_with_possible_json_value - async def _subscribe_topics(self): # noqa: C901 + async def _subscribe_topics(self): """(Re)Subscribe to topics.""" topics = {} @@ -405,7 +365,6 @@ class MqttFan(MqttEntity, FanEntity): return if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: self._percentage = None - self._speed = None self.async_write_ha_state() return try: @@ -471,51 +430,6 @@ class MqttFan(MqttEntity, FanEntity): } self._preset_mode = None - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - @callback - @log_messages(self.hass, self.entity_id) - def speed_received(msg): - """Handle new received MQTT message for the speed.""" - speed_payload = self._value_templates[ATTR_SPEED](msg.payload) - if speed_payload == self._payload["SPEED_LOW"]: - speed = SPEED_LOW - elif speed_payload == self._payload["SPEED_MEDIUM"]: - speed = SPEED_MEDIUM - elif speed_payload == self._payload["SPEED_HIGH"]: - speed = SPEED_HIGH - elif speed_payload == self._payload["SPEED_OFF"]: - speed = SPEED_OFF - else: - speed = None - - if speed and speed in self._legacy_speeds_list: - self._speed = speed - else: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid speed", - msg.payload, - msg.topic, - speed, - ) - return - - if speed in self._legacy_speeds_list_no_off: - self._percentage = ordered_list_item_to_percentage( - self._legacy_speeds_list_no_off, speed - ) - elif speed == SPEED_OFF: - self._percentage = 0 - - self.async_write_ha_state() - - if self._topic[CONF_SPEED_STATE_TOPIC] is not None: - topics[CONF_SPEED_STATE_TOPIC] = { - "topic": self._topic[CONF_SPEED_STATE_TOPIC], - "msg_callback": speed_received, - "qos": self._config[CONF_QOS], - } - self._speed = SPEED_OFF - @callback @log_messages(self.hass, self.entity_id) def oscillation_received(msg): @@ -552,12 +466,6 @@ class MqttFan(MqttEntity, FanEntity): """Return true if device is on.""" return self._state - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - @property - def _implemented_speed(self) -> bool: - """Return true if speed has been implemented.""" - return self._feature_legacy_speeds - @property def percentage(self): """Return the current percentage.""" @@ -573,22 +481,11 @@ class MqttFan(MqttEntity, FanEntity): """Get the list of available preset modes.""" return self._preset_modes - # The speed_list property is deprecated in the schema, support will be removed after a quarter (2021.7) - @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return self._legacy_speeds_list_no_off - @property def supported_features(self) -> int: """Flag supported features.""" return self._supported_features - @property - def speed(self): - """Return the current speed.""" - return self._speed - @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" @@ -623,9 +520,6 @@ class MqttFan(MqttEntity, FanEntity): await self.async_set_percentage(percentage) if preset_mode: await self.async_set_preset_mode(preset_mode) - # The speed attribute deprecated in the schema, support will be removed after a quarter (2021.7) - if speed and not percentage and not preset_mode: - await self.async_set_speed(speed) if self._optimistic: self._state = True self.async_write_ha_state() @@ -656,26 +550,13 @@ class MqttFan(MqttEntity, FanEntity): percentage_to_ranged_value(self._speed_range, percentage) ) mqtt_payload = self._command_templates[ATTR_PERCENTAGE](percentage_payload) - # Legacy are deprecated in the schema, support will be removed after a quarter (2021.7) - if self._feature_legacy_speeds: - if percentage: - await self.async_set_speed( - percentage_to_ordered_list_item( - self._legacy_speeds_list_no_off, - percentage, - ) - ) - elif SPEED_OFF in self._legacy_speeds_list: - await self.async_set_speed(SPEED_OFF) - - if self._feature_percentage: - mqtt.async_publish( - self.hass, - self._topic[CONF_PERCENTAGE_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - ) + mqtt.async_publish( + self.hass, + self._topic[CONF_PERCENTAGE_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) if self._optimistic_percentage: self._percentage = percentage @@ -704,39 +585,6 @@ class MqttFan(MqttEntity, FanEntity): self._preset_mode = preset_mode self.async_write_ha_state() - # async_set_speed is deprecated, support will be removed after a quarter (2021.7) - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan. - - This method is a coroutine. - """ - speed_payload = None - if speed in self._legacy_speeds_list: - if speed == SPEED_LOW: - speed_payload = self._payload["SPEED_LOW"] - elif speed == SPEED_MEDIUM: - speed_payload = self._payload["SPEED_MEDIUM"] - elif speed == SPEED_HIGH: - speed_payload = self._payload["SPEED_HIGH"] - else: - speed_payload = self._payload["SPEED_OFF"] - else: - _LOGGER.warning("'%s' is not a valid speed", speed) - return - - if speed_payload: - mqtt.async_publish( - self.hass, - self._topic[CONF_SPEED_COMMAND_TOPIC], - speed_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - ) - - if self._optimistic_speed and speed_payload: - self._speed = speed - self.async_write_ha_state() - async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation. diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index a40f06a3bb6..11bf70ceceb 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -330,7 +330,10 @@ class MqttAvailability(Entity): self.async_write_ha_state() - self._available = {topic: False for topic in self._avail_topics} + self._available = { + topic: (self._available[topic] if topic in self._available else False) + for topic in self._avail_topics + } topics = { f"availability_{topic}": { "topic": topic, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 239af7b450a..4a0ea75de21 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,18 +53,48 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +def validate_options(conf): + """Validate options. + + If last reset topic is present it must be same as the state topic. + """ + if ( + CONF_LAST_RESET_TOPIC in conf + and CONF_STATE_TOPIC in conf + and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC] + ): + _LOGGER.warning( + "'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC + ) + + if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf: + _LOGGER.warning( + "'%s' must be set if '%s' is set", + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_LAST_RESET_TOPIC, + ) + + return conf + + +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_LAST_RESET_TOPIC), + mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), + validate_options, +) async def async_setup_platform( @@ -127,10 +157,7 @@ class MqttSensor(MqttEntity, SensorEntity): """(Re)Subscribe to topics.""" topics = {} - @callback - @log_messages(self.hass, self.entity_id) - def message_received(msg): - """Handle new MQTT messages.""" + def _update_state(msg): payload = msg.payload # auto-expire enabled? expire_after = self._config.get(CONF_EXPIRE_AFTER) @@ -159,18 +186,8 @@ class MqttSensor(MqttEntity, SensorEntity): variables=variables, ) self._state = payload - self.async_write_ha_state() - topics["state_topic"] = { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - } - - @callback - @log_messages(self.hass, self.entity_id) - def last_reset_message_received(msg): - """Handle new last_reset messages.""" + def _update_last_reset(msg): payload = msg.payload template = self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE) @@ -193,9 +210,36 @@ class MqttSensor(MqttEntity, SensorEntity): _LOGGER.warning( "Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic ) + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg): + """Handle new MQTT messages.""" + _update_state(msg) + if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and ( + CONF_LAST_RESET_TOPIC not in self._config + or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC] + ): + _update_last_reset(msg) self.async_write_ha_state() - if CONF_LAST_RESET_TOPIC in self._config: + topics["state_topic"] = { + "topic": self._config[CONF_STATE_TOPIC], + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + } + + @callback + @log_messages(self.hass, self.entity_id) + def last_reset_message_received(msg): + """Handle new last_reset messages.""" + _update_last_reset(msg) + self.async_write_ha_state() + + if ( + CONF_LAST_RESET_TOPIC in self._config + and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC] + ): topics["last_reset_topic"] = { "topic": self._config[CONF_LAST_RESET_TOPIC], "msg_callback": last_reset_message_received, @@ -214,7 +258,7 @@ class MqttSensor(MqttEntity, SensorEntity): self.async_write_ha_state() @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._config.get(CONF_UNIT_OF_MEASUREMENT) @@ -224,7 +268,7 @@ class MqttSensor(MqttEntity, SensorEntity): return self._config[CONF_FORCE_UPDATE] @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self._state diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9de9075f19d..155f9fcb4f2 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -20,6 +20,7 @@ } }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { diff --git a/homeassistant/components/mqtt/translations/en.json b/homeassistant/components/mqtt/translations/en.json index 775b4d21c9b..23012946a71 100644 --- a/homeassistant/components/mqtt/translations/en.json +++ b/homeassistant/components/mqtt/translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Service is already configured", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { diff --git a/homeassistant/components/mqtt/translations/hu.json b/homeassistant/components/mqtt/translations/hu.json index 84c4a40f082..a519cab55d3 100644 --- a/homeassistant/components/mqtt/translations/hu.json +++ b/homeassistant/components/mqtt/translations/hu.json @@ -50,6 +50,8 @@ }, "options": { "error": { + "bad_birth": "\u00c9rv\u00e9nytelen sz\u00fclet\u00e9si t\u00e9ma.", + "bad_will": "\u00c9rv\u00e9nytelen t\u00e9ma.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { @@ -59,9 +61,25 @@ "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "K\u00e9rlek, add meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.", + "title": "Br\u00f3ker opci\u00f3k" }, "options": { + "data": { + "birth_enable": "Sz\u00fclet\u00e9si \u00fczenet enged\u00e9lyez\u00e9se", + "birth_payload": "Sz\u00fclet\u00e9si \u00fczenet", + "birth_qos": "Sz\u00fclet\u00e9si \u00fczenet QoS", + "birth_retain": "A sz\u00fclet\u00e9si \u00fczenet meg\u0151rz\u00e9se", + "birth_topic": "Sz\u00fclet\u00e9si \u00fczenet t\u00e9m\u00e1ja", + "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se", + "will_enable": "Enged\u00e9lyez\u00e9si \u00fczenet", + "will_payload": "\u00dczenet", + "will_qos": "QoS \u00fczenet", + "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.", "title": "MQTT opci\u00f3k" } } diff --git a/homeassistant/components/mqtt/translations/lt.json b/homeassistant/components/mqtt/translations/lt.json new file mode 100644 index 00000000000..35257770c75 --- /dev/null +++ b/homeassistant/components/mqtt/translations/lt.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepavyko prisijungti" + }, + "step": { + "broker": { + "data": { + "password": "Slapta\u017eodis", + "port": "Portas", + "username": "Prisijungimo vardas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index b40d550abf6..479b02ebcbd 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -139,7 +139,7 @@ class MQTTRoomSensor(SensorEntity): return {ATTR_DISTANCE: self._distance} @property - def state(self): + def native_value(self): """Return the current room of the entity.""" return self._state diff --git a/homeassistant/components/mutesync/translations/de.json b/homeassistant/components/mutesync/translations/de.json index 613cac29b1c..dccab9e8d1e 100644 --- a/homeassistant/components/mutesync/translations/de.json +++ b/homeassistant/components/mutesync/translations/de.json @@ -2,7 +2,7 @@ "config": { "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Aktivieredie Authentifizierung in den Einstellungen von m\u00fctesync > Authentifizierung", + "invalid_auth": "Aktiviere die Authentifizierung in den Einstellungen von m\u00fctesync > Authentifizierung", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index 953fe4c69a8..416ce21cbaf 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -108,7 +108,7 @@ class MVGLiveSensor(SensorEntity): return self._station @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state @@ -128,7 +128,7 @@ class MVGLiveSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/mychevy/sensor.py b/homeassistant/components/mychevy/sensor.py index 18b5e95d838..1a5613d8864 100644 --- a/homeassistant/components/mychevy/sensor.py +++ b/homeassistant/components/mychevy/sensor.py @@ -98,7 +98,7 @@ class MyChevyStatus(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state.""" return self._state @@ -166,7 +166,7 @@ class EVSensor(SensorEntity): self.async_write_ha_state() @property - def state(self): + def native_value(self): """Return the state.""" return self._state @@ -176,7 +176,7 @@ class EVSensor(SensorEntity): return self._state_attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement the state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index 063f044117e..253c10544c9 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -3,6 +3,13 @@ from datetime import timedelta import logging import pymyq +from pymyq.const import ( + DEVICE_STATE as MYQ_DEVICE_STATE, + DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, + KNOWN_MODELS, + MANUFACTURER, +) +from pymyq.device import MyQDevice from pymyq.errors import InvalidCredentialsError, MyQError from homeassistant.config_entries import ConfigEntry @@ -10,7 +17,11 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, PLATFORMS, UPDATE_INTERVAL @@ -63,3 +74,46 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +class MyQEntity(CoordinatorEntity): + """Base class for MyQ Entities.""" + + def __init__(self, coordinator: DataUpdateCoordinator, device: MyQDevice) -> None: + """Initialize class.""" + super().__init__(coordinator) + self._device = device + self._attr_unique_id = device.device_id + + @property + def name(self): + """Return the name if any, name can change if user changes it within MyQ.""" + return self._device.name + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self._device.name, + "manufacturer": MANUFACTURER, + "sw_version": self._device.firmware_version, + } + model = ( + KNOWN_MODELS.get(self._device.device_id[2:4]) + if self._device.device_id is not None + else None + ) + if model: + device_info["model"] = model + if self._device.parent_device_id: + device_info["via_device"] = (DOMAIN, self._device.parent_device_id) + return device_info + + @property + def available(self): + """Return if the device is online.""" + # Not all devices report online so assume True if its missing + return super().available and self._device.device_json[MYQ_DEVICE_STATE].get( + MYQ_DEVICE_STATE_ONLINE, True + ) diff --git a/homeassistant/components/myq/binary_sensor.py b/homeassistant/components/myq/binary_sensor.py index 96ab589253b..9f2d766fcc4 100644 --- a/homeassistant/components/myq/binary_sensor.py +++ b/homeassistant/components/myq/binary_sensor.py @@ -1,17 +1,10 @@ """Support for MyQ gateways.""" -from pymyq.const import ( - DEVICE_STATE as MYQ_DEVICE_STATE, - DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, - KNOWN_MODELS, - MANUFACTURER, -) - from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, BinarySensorEntity, ) -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import MyQEntity from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY @@ -29,16 +22,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(entities) -class MyQBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): +class MyQBinarySensorEntity(MyQEntity, BinarySensorEntity): """Representation of a MyQ gateway.""" _attr_device_class = DEVICE_CLASS_CONNECTIVITY - def __init__(self, coordinator, device): - """Initialize with API object, device id.""" - super().__init__(coordinator) - self._device = device - @property def name(self): """Return the name of the garage door if any.""" @@ -47,35 +35,9 @@ class MyQBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): @property def is_on(self): """Return if the device is online.""" - if not self.coordinator.last_update_success: - return False - - # Not all devices report online so assume True if its missing - return self._device.device_json[MYQ_DEVICE_STATE].get( - MYQ_DEVICE_STATE_ONLINE, True - ) + return super().available @property def available(self) -> bool: """Entity is always available.""" return True - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._device.device_id - - @property - def device_info(self): - """Return the device_info of the device.""" - device_info = { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self.name, - "manufacturer": MANUFACTURER, - "sw_version": self._device.firmware_version, - } - model = KNOWN_MODELS.get(self._device.device_id[2:4]) - if model: - device_info["model"] = model - - return device_info diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py index 6189b1601ea..9f3a434ae37 100644 --- a/homeassistant/components/myq/const.py +++ b/homeassistant/components/myq/const.py @@ -5,18 +5,28 @@ from pymyq.garagedoor import ( STATE_OPEN as MYQ_COVER_STATE_OPEN, STATE_OPENING as MYQ_COVER_STATE_OPENING, ) +from pymyq.lamp import STATE_OFF as MYQ_LIGHT_STATE_OFF, STATE_ON as MYQ_LIGHT_STATE_ON -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING +from homeassistant.const import ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OFF, + STATE_ON, + STATE_OPEN, + STATE_OPENING, +) DOMAIN = "myq" -PLATFORMS = ["cover", "binary_sensor"] +PLATFORMS = ["cover", "binary_sensor", "light"] MYQ_TO_HASS = { MYQ_COVER_STATE_CLOSED: STATE_CLOSED, MYQ_COVER_STATE_CLOSING: STATE_CLOSING, MYQ_COVER_STATE_OPEN: STATE_OPEN, MYQ_COVER_STATE_OPENING: STATE_OPENING, + MYQ_LIGHT_STATE_ON: STATE_ON, + MYQ_LIGHT_STATE_OFF: STATE_OFF, } MYQ_GATEWAY = "myq_gateway" diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 8d36db8e0ab..e8e06dc3b22 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -1,13 +1,7 @@ """Support for MyQ-Enabled Garage Doors.""" import logging -from pymyq.const import ( - DEVICE_STATE as MYQ_DEVICE_STATE, - DEVICE_STATE_ONLINE as MYQ_DEVICE_STATE_ONLINE, - DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE, - KNOWN_MODELS, - MANUFACTURER, -) +from pymyq.const import DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE from pymyq.errors import MyQError from homeassistant.components.cover import ( @@ -18,8 +12,9 @@ from homeassistant.components.cover import ( CoverEntity, ) from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.exceptions import HomeAssistantError +from . import MyQEntity from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS _LOGGER = logging.getLogger(__name__) @@ -32,41 +27,24 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = data[MYQ_COORDINATOR] async_add_entities( - [MyQDevice(coordinator, device) for device in myq.covers.values()] + [MyQCover(coordinator, device) for device in myq.covers.values()] ) -class MyQDevice(CoordinatorEntity, CoverEntity): +class MyQCover(MyQEntity, CoverEntity): """Representation of a MyQ cover.""" + _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + def __init__(self, coordinator, device): """Initialize with API object, device id.""" - super().__init__(coordinator) + super().__init__(coordinator, device) self._device = device - - @property - def device_class(self): - """Define this cover as a garage door.""" - device_type = self._device.device_type - if device_type is not None and device_type == MYQ_DEVICE_TYPE_GATE: - return DEVICE_CLASS_GATE - return DEVICE_CLASS_GARAGE - - @property - def name(self): - """Return the name of the garage door if any.""" - return self._device.name - - @property - def available(self): - """Return if the device is online.""" - if not self.coordinator.last_update_success: - return False - - # Not all devices report online so assume True if its missing - return self._device.device_json[MYQ_DEVICE_STATE].get( - MYQ_DEVICE_STATE_ONLINE, True - ) + if device.device_type == MYQ_DEVICE_TYPE_GATE: + self._attr_device_class = DEVICE_CLASS_GATE + else: + self._attr_device_class = DEVICE_CLASS_GARAGE + self._attr_unique_id = device.device_id @property def is_closed(self): @@ -88,16 +66,6 @@ class MyQDevice(CoordinatorEntity, CoverEntity): """Return if the cover is opening or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._device.device_id - async def async_close_cover(self, **kwargs): """Issue close command to cover.""" if self.is_closing or self.is_closed: @@ -106,23 +74,21 @@ class MyQDevice(CoordinatorEntity, CoverEntity): try: wait_task = await self._device.close(wait_for_state=False) except MyQError as err: - _LOGGER.error( - "Closing of cover %s failed with error: %s", self._device.name, str(err) - ) - - return + raise HomeAssistantError( + f"Closing of cover {self._device.name} failed with error: {err}" + ) from err # Write closing state to HASS self.async_write_ha_state() result = wait_task if isinstance(wait_task, bool) else await wait_task - if not result: - _LOGGER.error("Closing of cover %s failed", self._device.name) - # Write final state to HASS self.async_write_ha_state() + if not result: + raise HomeAssistantError(f"Closing of cover {self._device.name} failed") + async def async_open_cover(self, **kwargs): """Issue open command to cover.""" if self.is_opening or self.is_open: @@ -131,34 +97,17 @@ class MyQDevice(CoordinatorEntity, CoverEntity): try: wait_task = await self._device.open(wait_for_state=False) except MyQError as err: - _LOGGER.error( - "Opening of cover %s failed with error: %s", self._device.name, str(err) - ) - return + raise HomeAssistantError( + f"Opening of cover {self._device.name} failed with error: {err}" + ) from err # Write opening state to HASS self.async_write_ha_state() result = wait_task if isinstance(wait_task, bool) else await wait_task - if not result: - _LOGGER.error("Opening of cover %s failed", self._device.name) - # Write final state to HASS self.async_write_ha_state() - @property - def device_info(self): - """Return the device_info of the device.""" - device_info = { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.name, - "manufacturer": MANUFACTURER, - "sw_version": self._device.firmware_version, - } - model = KNOWN_MODELS.get(self._device.device_id[2:4]) - if model: - device_info["model"] = model - if self._device.parent_device_id: - device_info["via_device"] = (DOMAIN, self._device.parent_device_id) - return device_info + if not result: + raise HomeAssistantError(f"Opening of cover {self._device.name} failed") diff --git a/homeassistant/components/myq/light.py b/homeassistant/components/myq/light.py new file mode 100644 index 00000000000..d8154d7c427 --- /dev/null +++ b/homeassistant/components/myq/light.py @@ -0,0 +1,70 @@ +"""Support for MyQ-Enabled lights.""" +import logging + +from pymyq.errors import MyQError + +from homeassistant.components.light import LightEntity +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.exceptions import HomeAssistantError + +from . import MyQEntity +from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, MYQ_TO_HASS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up myq lights.""" + data = hass.data[DOMAIN][config_entry.entry_id] + myq = data[MYQ_GATEWAY] + coordinator = data[MYQ_COORDINATOR] + + async_add_entities( + [MyQLight(coordinator, device) for device in myq.lamps.values()], True + ) + + +class MyQLight(MyQEntity, LightEntity): + """Representation of a MyQ light.""" + + _attr_supported_features = 0 + + @property + def is_on(self): + """Return true if the light is on, else False.""" + return MYQ_TO_HASS.get(self._device.state) == STATE_ON + + @property + def is_off(self): + """Return true if the light is off, else False.""" + return MYQ_TO_HASS.get(self._device.state) == STATE_OFF + + async def async_turn_on(self, **kwargs): + """Issue on command to light.""" + if self.is_on: + return + + try: + await self._device.turnon(wait_for_state=True) + except MyQError as err: + raise HomeAssistantError( + f"Turning light {self._device.name} on failed with error: {err}" + ) from err + + # Write new state to HASS + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Issue off command to light.""" + if self.is_off: + return + + try: + await self._device.turnoff(wait_for_state=True) + except MyQError as err: + raise HomeAssistantError( + f"Turning light {self._device.name} off failed with error: {err}" + ) from err + + # Write new state to HASS + self.async_write_ha_state() diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index a4de12290f1..fa9313eb9a1 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -2,8 +2,8 @@ "domain": "myq", "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", - "requirements": ["pymyq==3.1.2"], - "codeowners": ["@bdraco"], + "requirements": ["pymyq==3.1.3"], + "codeowners": ["@bdraco","@ehendrix23"], "config_flow": true, "homekit": { "models": ["819LMB", "MYQ"] diff --git a/homeassistant/components/myq/translations/hu.json b/homeassistant/components/myq/translations/hu.json index 59338cf43ae..f50099f023b 100644 --- a/homeassistant/components/myq/translations/hu.json +++ b/homeassistant/components/myq/translations/hu.json @@ -21,7 +21,8 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a MyQ Gateway-hez" } } } diff --git a/homeassistant/components/myq/translations/zh-Hans.json b/homeassistant/components/myq/translations/zh-Hans.json index a5f4ff11f09..db06c3cf23a 100644 --- a/homeassistant/components/myq/translations/zh-Hans.json +++ b/homeassistant/components/myq/translations/zh-Hans.json @@ -1,6 +1,12 @@ { "config": { "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "{username} \u7684\u5bc6\u7801\u5df2\u5931\u6548\u3002" + }, "user": { "data": { "username": "\u7528\u6237\u540d" diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index e1f4dd3d1e0..0c80cf31c9a 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -1,17 +1,17 @@ """Support for MySensors lights.""" from __future__ import annotations -from typing import Any +from typing import Any, Tuple, cast from homeassistant.components import mysensors from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_HS_COLOR, - ATTR_WHITE_VALUE, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, DOMAIN, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_WHITE_VALUE, LightEntity, ) from homeassistant.config_entries import ConfigEntry @@ -19,15 +19,12 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util from homeassistant.util.color import rgb_hex_to_rgb_list from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .device import MySensorsDevice from .helpers import on_unload -SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE - async def async_setup_entry( hass: HomeAssistant, @@ -69,24 +66,6 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): """Initialize a MySensors Light.""" super().__init__(*args) self._state: bool | None = None - self._brightness: int | None = None - self._hs: tuple[int, int] | None = None - self._white: int | None = None - - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def hs_color(self) -> tuple[int, int] | None: - """Return the hs color value [int, int].""" - return self._hs - - @property - def white_value(self) -> int | None: - """Return the white value of this light between 0..255.""" - return self._white @property def is_on(self) -> bool: @@ -114,7 +93,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): if ( ATTR_BRIGHTNESS not in kwargs - or kwargs[ATTR_BRIGHTNESS] == self._brightness + or kwargs[ATTR_BRIGHTNESS] == self._attr_brightness or set_req.V_DIMMER not in self._values ): return @@ -126,49 +105,9 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): if self.assumed_state: # optimistically assume that light has changed state - self._brightness = brightness + self._attr_brightness = brightness self._values[set_req.V_DIMMER] = percent - def _turn_on_rgb_and_w(self, hex_template: str, **kwargs: Any) -> None: - """Turn on RGB or RGBW child device.""" - assert self._hs - rgb = list(color_util.color_hs_to_RGB(*self._hs)) - white = self._white - hex_color = self._values.get(self.value_type) - hs_color: tuple[float, float] | None = kwargs.get(ATTR_HS_COLOR) - new_rgb: tuple[int, int, int] | None - if hs_color is not None: - new_rgb = color_util.color_hs_to_RGB(*hs_color) - else: - new_rgb = None - new_white: int | None = kwargs.get(ATTR_WHITE_VALUE) - - if new_rgb is None and new_white is None: - return - if new_rgb is not None: - rgb = list(new_rgb) - if hex_template == "%02x%02x%02x%02x": - if new_white is not None: - rgb.append(new_white) - elif white is not None: - rgb.append(white) - else: - rgb.append(0) - hex_color = hex_template % tuple(rgb) - if len(rgb) > 3: - white = rgb.pop() - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, hex_color, ack=1 - ) - - if self.assumed_state: - # optimistically assume that light has changed state - # pylint: disable=no-value-for-parameter - # https://github.com/PyCQA/pylint/issues/4546 - self._hs = color_util.color_RGB_to_hs(*rgb) # type: ignore[assignment] - self._white = white - self._values[self.value_type] = hex_color - async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT @@ -190,27 +129,16 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): """Update the controller with values from dimmer child.""" value_type = self.gateway.const.SetReq.V_DIMMER if value_type in self._values: - self._brightness = round(255 * int(self._values[value_type]) / 100) - if self._brightness == 0: + self._attr_brightness = round(255 * int(self._values[value_type]) / 100) + if self._attr_brightness == 0: self._state = False - @callback - def _async_update_rgb_or_w(self) -> None: - """Update the controller with values from RGB or RGBW child.""" - value = self._values[self.value_type] - color_list = rgb_hex_to_rgb_list(value) - if len(color_list) > 3: - self._white = color_list.pop() - self._hs = color_util.color_RGB_to_hs(*color_list) # type: ignore[assignment] - class MySensorsLightDimmer(MySensorsLight): """Dimmer child class to MySensorsLight.""" - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_BRIGHTNESS + _attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + _attr_color_mode = COLOR_MODE_BRIGHTNESS async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -229,22 +157,33 @@ class MySensorsLightDimmer(MySensorsLight): class MySensorsLightRGB(MySensorsLight): """RGB child class to MySensorsLight.""" - @property - def supported_features(self) -> int: - """Flag supported features.""" - set_req = self.gateway.const.SetReq - if set_req.V_DIMMER in self._values: - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR - return SUPPORT_COLOR + _attr_supported_color_modes = {COLOR_MODE_RGB} + _attr_color_mode = COLOR_MODE_RGB async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - self._turn_on_rgb_and_w("%02x%02x%02x", **kwargs) + self._turn_on_rgb(**kwargs) if self.assumed_state: self.async_write_ha_state() + def _turn_on_rgb(self, **kwargs: Any) -> None: + """Turn on RGB child device.""" + hex_color = self._values.get(self.value_type) + new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) + if new_rgb is None: + return + hex_color = "%02x%02x%02x" % new_rgb + self.gateway.set_child_value( + self.node_id, self.child_id, self.value_type, hex_color, ack=1 + ) + + if self.assumed_state: + # optimistically assume that light has changed state + self._attr_rgb_color = new_rgb + self._values[self.value_type] = hex_color + async def async_update(self) -> None: """Update the controller with the latest value from a sensor.""" await super().async_update() @@ -252,22 +191,49 @@ class MySensorsLightRGB(MySensorsLight): self._async_update_dimmer() self._async_update_rgb_or_w() + @callback + def _async_update_rgb_or_w(self) -> None: + """Update the controller with values from RGB child.""" + value = self._values[self.value_type] + self._attr_rgb_color = cast( + Tuple[int, int, int], tuple(rgb_hex_to_rgb_list(value)) + ) + class MySensorsLightRGBW(MySensorsLightRGB): """RGBW child class to MySensorsLightRGB.""" - @property - def supported_features(self) -> int: - """Flag supported features.""" - set_req = self.gateway.const.SetReq - if set_req.V_DIMMER in self._values: - return SUPPORT_BRIGHTNESS | SUPPORT_MYSENSORS_RGBW - return SUPPORT_MYSENSORS_RGBW + _attr_supported_color_modes = {COLOR_MODE_RGBW} + _attr_color_mode = COLOR_MODE_RGBW async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - self._turn_on_rgb_and_w("%02x%02x%02x%02x", **kwargs) + self._turn_on_rgbw(**kwargs) if self.assumed_state: self.async_write_ha_state() + + def _turn_on_rgbw(self, **kwargs: Any) -> None: + """Turn on RGBW child device.""" + hex_color = self._values.get(self.value_type) + new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) + if new_rgbw is None: + return + hex_color = "%02x%02x%02x%02x" % new_rgbw + self.gateway.set_child_value( + self.node_id, self.child_id, self.value_type, hex_color, ack=1 + ) + + if self.assumed_state: + # optimistically assume that light has changed state + self._attr_rgbw_color = new_rgbw + self._values[self.value_type] = hex_color + + @callback + def _async_update_rgb_or_w(self) -> None: + """Update the controller with values from RGBW child.""" + value = self._values[self.value_type] + self._attr_rgbw_color = cast( + Tuple[int, int, int, int], tuple(rgb_hex_to_rgb_list(value)) + ) diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index c7755b13512..94a9cde1df2 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,7 +1,7 @@ """Support for MySensors sensors.""" from __future__ import annotations -from datetime import datetime +from typing import Any from awesomeversion import AwesomeVersion @@ -9,7 +9,9 @@ from homeassistant.components import mysensors from homeassistant.components.sensor import ( DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -41,68 +43,153 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utc_from_timestamp from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .helpers import on_unload -SENSORS: dict[str, list[str | None] | dict[str, list[str | None]]] = { - "V_TEMP": [None, None, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT], - "V_HUM": [ - PERCENTAGE, - "mdi:water-percent", - DEVICE_CLASS_HUMIDITY, - STATE_CLASS_MEASUREMENT, - ], - "V_DIMMER": [PERCENTAGE, "mdi:percent", None, None], - "V_PERCENTAGE": [PERCENTAGE, "mdi:percent", None, None], - "V_PRESSURE": [None, "mdi:gauge", None, None], - "V_FORECAST": [None, "mdi:weather-partly-cloudy", None, None], - "V_RAIN": [None, "mdi:weather-rainy", None, None], - "V_RAINRATE": [None, "mdi:weather-rainy", None, None], - "V_WIND": [None, "mdi:weather-windy", None, None], - "V_GUST": [None, "mdi:weather-windy", None, None], - "V_DIRECTION": [DEGREE, "mdi:compass", None, None], - "V_WEIGHT": [MASS_KILOGRAMS, "mdi:weight-kilogram", None, None], - "V_DISTANCE": [LENGTH_METERS, "mdi:ruler", None, None], - "V_IMPEDANCE": ["ohm", None, None, None], - "V_WATT": [POWER_WATT, None, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT], - "V_KWH": [ - ENERGY_KILO_WATT_HOUR, - None, - DEVICE_CLASS_ENERGY, - STATE_CLASS_MEASUREMENT, - ], - "V_LIGHT_LEVEL": [PERCENTAGE, "mdi:white-balance-sunny", None, None], - "V_FLOW": [LENGTH_METERS, "mdi:gauge", None, None], - "V_VOLUME": [VOLUME_CUBIC_METERS, None, None, None], - "V_LEVEL": { - "S_SOUND": [SOUND_PRESSURE_DB, "mdi:volume-high", None, None], - "S_VIBRATION": [FREQUENCY_HERTZ, None, None, None], - "S_LIGHT_LEVEL": [ - LIGHT_LUX, - "mdi:white-balance-sunny", - DEVICE_CLASS_ILLUMINANCE, - STATE_CLASS_MEASUREMENT, - ], - }, - "V_VOLTAGE": [ - ELECTRIC_POTENTIAL_VOLT, - "mdi:flash", - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - ], - "V_CURRENT": [ - ELECTRIC_CURRENT_AMPERE, - "mdi:flash-auto", - DEVICE_CLASS_CURRENT, - STATE_CLASS_MEASUREMENT, - ], - "V_PH": ["pH", None, None, None], - "V_ORP": [ELECTRIC_POTENTIAL_MILLIVOLT, None, None, None], - "V_EC": [CONDUCTIVITY, None, None, None], - "V_VAR": ["var", None, None, None], - "V_VA": [POWER_VOLT_AMPERE, None, None, None], +SENSORS: dict[str, SensorEntityDescription] = { + "V_TEMP": SensorEntityDescription( + key="V_TEMP", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_HUM": SensorEntityDescription( + key="V_HUM", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_DIMMER": SensorEntityDescription( + key="V_DIMMER", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + "V_PERCENTAGE": SensorEntityDescription( + key="V_PERCENTAGE", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + "V_PRESSURE": SensorEntityDescription( + key="V_PRESSURE", + icon="mdi:gauge", + ), + "V_FORECAST": SensorEntityDescription( + key="V_FORECAST", + icon="mdi:weather-partly-cloudy", + ), + "V_RAIN": SensorEntityDescription( + key="V_RAIN", + icon="mdi:weather-rainy", + ), + "V_RAINRATE": SensorEntityDescription( + key="V_RAINRATE", + icon="mdi:weather-rainy", + ), + "V_WIND": SensorEntityDescription( + key="V_WIND", + icon="mdi:weather-windy", + ), + "V_GUST": SensorEntityDescription( + key="V_GUST", + icon="mdi:weather-windy", + ), + "V_DIRECTION": SensorEntityDescription( + key="V_DIRECTION", + native_unit_of_measurement=DEGREE, + icon="mdi:compass", + ), + "V_WEIGHT": SensorEntityDescription( + key="V_WEIGHT", + native_unit_of_measurement=MASS_KILOGRAMS, + icon="mdi:weight-kilogram", + ), + "V_DISTANCE": SensorEntityDescription( + key="V_DISTANCE", + native_unit_of_measurement=LENGTH_METERS, + icon="mdi:ruler", + ), + "V_IMPEDANCE": SensorEntityDescription( + key="V_IMPEDANCE", + native_unit_of_measurement="ohm", + ), + "V_WATT": SensorEntityDescription( + key="V_WATT", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_KWH": SensorEntityDescription( + key="V_KWH", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + "V_LIGHT_LEVEL": SensorEntityDescription( + key="V_LIGHT_LEVEL", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:white-balance-sunny", + ), + "V_FLOW": SensorEntityDescription( + key="V_FLOW", + native_unit_of_measurement=LENGTH_METERS, + icon="mdi:gauge", + ), + "V_VOLUME": SensorEntityDescription( + key="V_VOLUME", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + ), + "V_LEVEL_S_SOUND": SensorEntityDescription( + key="V_LEVEL_S_SOUND", + native_unit_of_measurement=SOUND_PRESSURE_DB, + icon="mdi:volume-high", + ), + "V_LEVEL_S_VIBRATION": SensorEntityDescription( + key="V_LEVEL_S_VIBRATION", + native_unit_of_measurement=FREQUENCY_HERTZ, + ), + "V_LEVEL_S_LIGHT_LEVEL": SensorEntityDescription( + key="V_LEVEL_S_LIGHT_LEVEL", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_LEVEL_S_MOISTURE": SensorEntityDescription( + key="V_LEVEL_S_MOISTURE", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + ), + "V_VOLTAGE": SensorEntityDescription( + key="V_VOLTAGE", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_CURRENT": SensorEntityDescription( + key="V_CURRENT", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + "V_PH": SensorEntityDescription( + key="V_PH", + native_unit_of_measurement="pH", + ), + "V_ORP": SensorEntityDescription( + key="V_ORP", + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + ), + "V_EC": SensorEntityDescription( + key="V_EC", + native_unit_of_measurement=CONDUCTIVITY, + ), + "V_VAR": SensorEntityDescription( + key="V_VAR", + native_unit_of_measurement="var", + ), + "V_VA": SensorEntityDescription( + key="V_VA", + native_unit_of_measurement=POWER_VOLT_AMPERE, + ), } @@ -137,46 +224,21 @@ async def async_setup_entry( class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): """Representation of a MySensors Sensor child node.""" - @property - def force_update(self) -> bool: - """Return True if state updates should be forced. + _attr_force_update = True - If True, a state change will be triggered anytime the state property is - updated, not just when the value changes. - """ - return True + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Set up the instance.""" + super().__init__(*args, **kwargs) + if entity_description := self._get_entity_description(): + self.entity_description = entity_description @property - def state(self) -> str | None: - """Return the state of this entity.""" + def native_value(self) -> str | None: + """Return the state of the sensor.""" return self._values.get(self.value_type) @property - def device_class(self) -> str | None: - """Return the device class of this entity.""" - return self._get_sensor_type()[2] - - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - return self._get_sensor_type()[1] - - @property - def last_reset(self) -> datetime | None: - """Return the time when the sensor was last reset, if any.""" - set_req = self.gateway.const.SetReq - - if set_req(self.value_type).name == "V_KWH": - return utc_from_timestamp(0) - return None - - @property - def state_class(self) -> str | None: - """Return the state class of this entity.""" - return self._get_sensor_type()[3] - - @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" set_req = self.gateway.const.SetReq if ( @@ -191,21 +253,19 @@ class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): return TEMP_CELSIUS return TEMP_FAHRENHEIT - unit = self._get_sensor_type()[0] - return unit + if hasattr(self, "entity_description"): + return self.entity_description.native_unit_of_measurement + return None - def _get_sensor_type(self) -> list[str | None]: - """Return list with unit and icon of sensor type.""" - pres = self.gateway.const.Presentation + def _get_entity_description(self) -> SensorEntityDescription | None: + """Return the sensor entity description.""" set_req = self.gateway.const.SetReq + entity_description = SENSORS.get(set_req(self.value_type).name) - _sensor_type = SENSORS.get( - set_req(self.value_type).name, [None, None, None, None] - ) - if isinstance(_sensor_type, dict): - sensor_type = _sensor_type.get( - pres(self.child_type).name, [None, None, None, None] + if not entity_description: + pres = self.gateway.const.Presentation + entity_description = SENSORS.get( + f"{set_req(self.value_type).name}_{pres(self.child_type).name}" ) - else: - sensor_type = _sensor_type - return sensor_type + + return entity_description diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index 85472deba06..da4831de9e5 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -13,6 +13,9 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, @@ -65,133 +68,133 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( key=ATTR_BME280_HUMIDITY, name=f"{DEFAULT_NAME} BME280 Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BME280_PRESSURE, name=f"{DEFAULT_NAME} BME280 Pressure", - unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_HPA, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BME280_TEMPERATURE, name=f"{DEFAULT_NAME} BME280 Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BMP280_PRESSURE, name=f"{DEFAULT_NAME} BMP280 Pressure", - unit_of_measurement=PRESSURE_HPA, + native_unit_of_measurement=PRESSURE_HPA, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_BMP280_TEMPERATURE, name=f"{DEFAULT_NAME} BMP280 Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_HECA_HUMIDITY, name=f"{DEFAULT_NAME} HECA Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_HECA_TEMPERATURE, name=f"{DEFAULT_NAME} HECA Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_MHZ14A_CARBON_DIOXIDE, name=f"{DEFAULT_NAME} MH-Z14A Carbon Dioxide", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=DEVICE_CLASS_CO2, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SDS011_P1, name=f"{DEFAULT_NAME} SDS011 Particulate Matter 10", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM10, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SDS011_P2, name=f"{DEFAULT_NAME} SDS011 Particulate Matter 2.5", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SHT3X_HUMIDITY, name=f"{DEFAULT_NAME} SHT3X Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SHT3X_TEMPERATURE, name=f"{DEFAULT_NAME} SHT3X Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P0, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 1.0", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM1, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P1, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 10", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM10, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P2, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 2.5", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=DEVICE_CLASS_PM25, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SPS30_P4, name=f"{DEFAULT_NAME} SPS30 Particulate Matter 4.0", - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:molecule", state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_DHT22_HUMIDITY, name=f"{DEFAULT_NAME} DHT22 Humidity", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_DHT22_TEMPERATURE, name=f"{DEFAULT_NAME} DHT22 Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), SensorEntityDescription( key=ATTR_SIGNAL_STRENGTH, name=f"{DEFAULT_NAME} Signal Strength", - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 298f88d5c29..c5c9c9f2e77 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -75,7 +75,7 @@ class NAMSensor(CoordinatorEntity, SensorEntity): self.entity_description = description @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state.""" return cast( StateType, getattr(self.coordinator.data, self.entity_description.key) @@ -99,7 +99,7 @@ class NAMSensorUptime(NAMSensor): """Define an Nettigo Air Monitor uptime sensor.""" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state.""" uptime_sec = getattr(self.coordinator.data, self.entity_description.key) return ( diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 776d6a61772..be61bbc65a3 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -1 +1,33 @@ -"""The nanoleaf component.""" +"""The Nanoleaf integration.""" +from pynanoleaf.pynanoleaf 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 .const import DEVICE, DOMAIN, NAME, SERIAL_NO +from .util import pynanoleaf_get_info + + +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] + try: + info = await hass.async_add_executor_job(pynanoleaf_get_info, nanoleaf) + 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.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) + return True diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py new file mode 100644 index 00000000000..9edfd23e6a9 --- /dev/null +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -0,0 +1,220 @@ +"""Config flow for Nanoleaf integration.""" +from __future__ import annotations + +import logging +import os +from typing import Any, Final, cast + +from pynanoleaf import InvalidToken, Nanoleaf, NotAuthorizingNewTokens, 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.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__) + +# For discovery integration import +CONFIG_FILE: Final = ".nanoleaf.conf" + +USER_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Nanoleaf config flow.""" + + reauth_entry: config_entries.ConfigEntry | None = None + + VERSION = 1 + + def __init__(self) -> None: + """Initialize a Nanoleaf flow.""" + self.nanoleaf: Nanoleaf + + # For discovery integration import + self.discovery_conf: dict + self.device_id: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle Nanoleaf flow initiated by the user.""" + if user_input is None: + return self.async_show_form( + 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]) + try: + await self.hass.async_add_executor_job(self.nanoleaf.authorize) + except Unavailable: + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + errors={"base": "cannot_connect"}, + last_step=False, + ) + except NotAuthorizingNewTokens: + pass + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error connecting to Nanoleaf") + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + last_step=False, + errors={"base": "unknown"}, + ) + return await self.async_step_link() + + async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + """Handle Nanoleaf reauth flow if token is invalid.""" + self.reauth_entry = cast( + config_entries.ConfigEntry, + self.hass.config_entries.async_get_entry(self.context["entry_id"]), + ) + self.nanoleaf = Nanoleaf(data[CONF_HOST]) + self.context["title_placeholders"] = {"name": self.reauth_entry.title} + return await self.async_step_link() + + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle Nanoleaf Zeroconf discovery.""" + _LOGGER.debug("Zeroconf discovered: %s", discovery_info) + return await self._async_discovery_handler(discovery_info) + + async def async_step_homekit(self, discovery_info: DiscoveryInfoType) -> FlowResult: + """Handle Nanoleaf Homekit discovery.""" + _LOGGER.debug("Homekit discovered: %s", discovery_info) + return await self._async_discovery_handler(discovery_info) + + async def _async_discovery_handler( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle Nanoleaf discovery.""" + host = discovery_info["host"] + # The name is unique and printed on the device and cannot be changed. + 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"] + self.discovery_conf = cast( + dict, + await self.hass.async_add_executor_job( + load_json, self.hass.config.path(CONFIG_FILE) + ), + ) + self.nanoleaf.token = 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: + _LOGGER.warning( + "Importing Nanoleaf %s from the discovery integration", name + ) + return await self.async_setup_finish(discovery_integration_import=True) + + self.context["title_placeholders"] = {"name": name} + return await self.async_step_link() + + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle Nanoleaf link step.""" + if user_input is None: + return self.async_show_form(step_id="link") + + try: + await self.hass.async_add_executor_job(self.nanoleaf.authorize) + except NotAuthorizingNewTokens: + return self.async_show_form( + step_id="link", errors={"base": "not_allowing_new_tokens"} + ) + except Unavailable: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error authorizing Nanoleaf") + return self.async_show_form(step_id="link", errors={"base": "unknown"}) + + if self.reauth_entry is not None: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_TOKEN: self.nanoleaf.token, + }, + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return await self.async_setup_finish() + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle Nanoleaf configuration import.""" + self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) + _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] + return await self.async_setup_finish() + + async def async_setup_finish( + self, discovery_integration_import: bool = False + ) -> FlowResult: + """Finish Nanoleaf config flow.""" + try: + info = await self.hass.async_add_executor_job( + pynanoleaf_get_info, self.nanoleaf + ) + except Unavailable: + return self.async_abort(reason="cannot_connect") + except InvalidToken: + return self.async_abort(reason="invalid_token") + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error connecting with Nanoleaf at %s", self.nanoleaf.host + ) + return self.async_abort(reason="unknown") + name = info["name"] + + await self.async_set_unique_id(name) + self._abort_if_unique_id_configured({CONF_HOST: self.nanoleaf.host}) + + if discovery_integration_import: + if self.nanoleaf.host in self.discovery_conf: + self.discovery_conf.pop(self.nanoleaf.host) + if self.device_id in self.discovery_conf: + self.discovery_conf.pop(self.device_id) + _LOGGER.info( + "Successfully imported Nanoleaf %s from the discovery integration", + name, + ) + if self.discovery_conf: + await self.hass.async_add_executor_job( + save_json, self.hass.config.path(CONFIG_FILE), self.discovery_conf + ) + else: + await self.hass.async_add_executor_job( + os.remove, self.hass.config.path(CONFIG_FILE) + ) + + return self.async_create_entry( + title=name, + data={ + CONF_HOST: self.nanoleaf.host, + CONF_TOKEN: self.nanoleaf.token, + }, + ) diff --git a/homeassistant/components/nanoleaf/const.py b/homeassistant/components/nanoleaf/const.py new file mode 100644 index 00000000000..6d393fa3428 --- /dev/null +++ b/homeassistant/components/nanoleaf/const.py @@ -0,0 +1,7 @@ +"""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 a6f453ce2aa..b50edf82179 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -1,7 +1,9 @@ """Support for Nanoleaf Lights.""" +from __future__ import annotations + import logging -from pynanoleaf import Nanoleaf, Unavailable +from pynanoleaf import Unavailable import voluptuous as vol from homeassistant.components.light import ( @@ -18,22 +20,23 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, LightEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, ) -from homeassistant.util.json import load_json, save_json + +from .const import DEVICE, DOMAIN, NAME, SERIAL_NO _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Nanoleaf" -DATA_NANOLEAF = "nanoleaf" - -CONFIG_FILE = ".nanoleaf.conf" - ICON = "mdi:triangle-outline" SUPPORT_NANOLEAF = ( @@ -53,69 +56,36 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Import Nanoleaf light platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: config[CONF_HOST], CONF_TOKEN: config[CONF_TOKEN]}, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Nanoleaf light.""" - - if DATA_NANOLEAF not in hass.data: - hass.data[DATA_NANOLEAF] = {} - - token = "" - if discovery_info is not None: - host = discovery_info["host"] - name = None - device_id = discovery_info["properties"]["id"] - - # if device already exists via config, skip discovery setup - if host in hass.data[DATA_NANOLEAF]: - return - _LOGGER.info("Discovered a new Nanoleaf: %s", discovery_info) - conf = load_json(hass.config.path(CONFIG_FILE)) - if host in conf and device_id not in conf: - conf[device_id] = conf.pop(host) - save_json(hass.config.path(CONFIG_FILE), conf) - token = conf.get(device_id, {}).get("token", "") - else: - host = config[CONF_HOST] - name = config[CONF_NAME] - token = config[CONF_TOKEN] - - nanoleaf_light = Nanoleaf(host) - - if not token: - token = nanoleaf_light.request_token() - if not token: - _LOGGER.error( - "Could not generate the auth token, did you press " - "and hold the power button on %s" - "for 5-7 seconds?", - name, - ) - return - conf = load_json(hass.config.path(CONFIG_FILE)) - conf[host] = {"token": token} - save_json(hass.config.path(CONFIG_FILE), conf) - - nanoleaf_light.token = token - - try: - info = nanoleaf_light.info - except Unavailable: - _LOGGER.error("Could not connect to Nanoleaf Light: %s on %s", name, host) - return - - if name is None: - name = info.name - - hass.data[DATA_NANOLEAF][host] = nanoleaf_light - add_entities([NanoleafLight(nanoleaf_light, name)], True) + data = hass.data[DOMAIN][entry.entry_id] + async_add_entities([NanoleafLight(data[DEVICE], data[NAME], data[SERIAL_NO])], True) class NanoleafLight(LightEntity): """Representation of a Nanoleaf Light.""" - def __init__(self, light, name): + def __init__(self, light, name, unique_id): """Initialize an Nanoleaf light.""" - self._unique_id = light.serialNo + self._unique_id = unique_id self._available = True self._brightness = None self._color_temp = None @@ -239,7 +209,6 @@ class NanoleafLight(LightEntity): def update(self): """Fetch new state data for this light.""" - try: self._available = self._light.available self._brightness = self._light.brightness diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 0984962fb73..42a9f512d3d 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -1,8 +1,15 @@ { "domain": "nanoleaf", "name": "Nanoleaf", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", "requirements": ["pynanoleaf==0.1.0"], - "codeowners": [], + "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."], + "homekit" : { + "models": [ + "NL*" + ] + }, + "codeowners": ["@milanmeu"], "iot_class": "local_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json new file mode 100644 index 00000000000..96fcfd2622a --- /dev/null +++ b/homeassistant/components/nanoleaf/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "link": { + "title": "Link Nanoleaf", + "description": "Press and hold the power button on your Nanoleaf for 5 seconds until the button LEDs start flashing, then click **SUBMIT** within 30 seconds." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "not_allowing_new_tokens": "Nanoleaf is not allowing new tokens, follow the instructions above.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_token": "[%key:common::config_flow::error::invalid_access_token%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/nanoleaf/translations/ca.json b/homeassistant/components/nanoleaf/translations/ca.json new file mode 100644 index 00000000000..80403026c91 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_token": "Token d'acc\u00e9s no v\u00e0lid", + "unknown": "Error inesperat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "not_allowing_new_tokens": "Nanoleaf no permet 'tokens' nous, segueix les instruccions de sobre.", + "unknown": "Error inesperat" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Prem i mant\u00e9 el bot\u00f3 d'engegada del Nanoleaf durant 5 segons fins que els LEDs del bot\u00f3 comencin a parpellejar, despr\u00e9s clica a **ENVIA** abans de que passin 30 segons.", + "title": "Enlla\u00e7 Nanoleaf" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/de.json b/homeassistant/components/nanoleaf/translations/de.json new file mode 100644 index 00000000000..b79c2995cb4 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/de.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_token": "Ung\u00fcltiger Zugriffs-Token", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "not_allowing_new_tokens": "Nanoleaf l\u00e4sst keine neuen Tokens zu, befolge die obigen Anweisungen.", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Halte die Ein-/Aus-Taste an deinem Nanoleaf 5 Sekunden lang gedr\u00fcckt, bis die LEDs der Tasten zu blinken beginnen, und klicke dann innerhalb von 30 Sekunden auf **SENDEN**.", + "title": "Nanoleaf verkn\u00fcpfen" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/translations/en.json b/homeassistant/components/nanoleaf/translations/en.json new file mode 100644 index 00000000000..7696f056aa3 --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "invalid_token": "Invalid access token", + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect", + "not_allowing_new_tokens": "Nanoleaf is not allowing new tokens, follow the instructions above.", + "unknown": "Unexpected error" + }, + "flow_title": "{name}", + "step": { + "link": { + "description": "Press and hold the power button on your Nanoleaf for 5 seconds until the button LEDs start flashing, then click **SUBMIT** within 30 seconds.", + "title": "Link Nanoleaf" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nanoleaf/util.py b/homeassistant/components/nanoleaf/util.py new file mode 100644 index 00000000000..0031622e90b --- /dev/null +++ b/homeassistant/components/nanoleaf/util.py @@ -0,0 +1,7 @@ +"""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/__init__.py b/homeassistant/components/neato/__init__.py index 28569e0f1d7..6310e81cdd0 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -1,7 +1,7 @@ """Support for Neato botvac connected vacuum cleaners.""" -from datetime import timedelta import logging +import aiohttp from pybotvac import Account, Neato from pybotvac.exceptions import NeatoException import voluptuous as vol @@ -12,17 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle from . import api, config_flow -from .const import ( - NEATO_CONFIG, - NEATO_DOMAIN, - NEATO_LOGIN, - NEATO_MAP_DATA, - NEATO_PERSISTENT_MAPS, - NEATO_ROBOTS, -) +from .const import NEATO_CONFIG, NEATO_DOMAIN, NEATO_LOGIN +from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -77,10 +70,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as ex: + _LOGGER.debug("API error: %s (%s)", ex.code, ex.message) + if ex.code in (401, 403): + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + raise ConfigEntryNotReady from ex + neato_session = api.ConfigEntryAuth(hass, entry, implementation) hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session hub = NeatoHub(hass, Account(neato_session)) + await hub.async_update_entry_unique_id(entry) + try: await hass.async_add_executor_job(hub.update_robots) except NeatoException as ex: @@ -94,32 +98,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigType) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[NEATO_DOMAIN].pop(entry.entry_id) return unload_ok - - -class NeatoHub: - """A My Neato hub wrapper class.""" - - def __init__(self, hass: HomeAssistant, neato: Account) -> None: - """Initialize the Neato hub.""" - self._hass = hass - self.my_neato: Account = neato - - @Throttle(timedelta(minutes=1)) - def update_robots(self): - """Update the robot states.""" - _LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS)) - self._hass.data[NEATO_ROBOTS] = self.my_neato.robots - self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps - self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps - - def download_map(self, url): - """Download a new map image.""" - map_image_data = self.my_neato.get_map_image(url) - return map_image_data diff --git a/homeassistant/components/neato/api.py b/homeassistant/components/neato/api.py index a22b1b48e74..cd26b009040 100644 --- a/homeassistant/components/neato/api.py +++ b/homeassistant/components/neato/api.py @@ -1,5 +1,8 @@ """API for Neato Botvac bound to Home Assistant OAuth.""" +from __future__ import annotations + from asyncio import run_coroutine_threadsafe +from typing import Any import pybotvac @@ -7,7 +10,7 @@ from homeassistant import config_entries, core from homeassistant.helpers import config_entry_oauth2_flow -class ConfigEntryAuth(pybotvac.OAuthSession): +class ConfigEntryAuth(pybotvac.OAuthSession): # type: ignore[misc] """Provide Neato Botvac authentication tied to an OAuth2 based config entry.""" def __init__( @@ -29,7 +32,7 @@ class ConfigEntryAuth(pybotvac.OAuthSession): self.session.async_ensure_token_valid(), self.hass.loop ).result() - return self.session.token["access_token"] + return self.session.token["access_token"] # type: ignore[no-any-return] class NeatoImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): @@ -39,7 +42,7 @@ class NeatoImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): """ @property - def extra_authorize_data(self) -> dict: + def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" return {"client_secret": self.client_secret} diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 9a2f47bcfa3..392d586068d 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -1,10 +1,20 @@ """Support for loading picture from Neato.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pybotvac.exceptions import NeatoRobotException +from pybotvac.robot import Robot +from urllib3.response import HTTPResponse from homeassistant.components.camera import Camera +from homeassistant.components.neato import NeatoHub +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 .const import ( NEATO_DOMAIN, @@ -20,11 +30,13 @@ SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES) ATTR_GENERATED_AT = "generated_at" -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 Neato camera with config entry.""" dev = [] - neato = hass.data.get(NEATO_LOGIN) - mapdata = hass.data.get(NEATO_MAP_DATA) + neato: NeatoHub = hass.data[NEATO_LOGIN] + mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA) for robot in hass.data[NEATO_ROBOTS]: if "maps" in robot.traits: dev.append(NeatoCleaningMap(neato, robot, mapdata)) @@ -39,7 +51,9 @@ async def async_setup_entry(hass, entry, async_add_entities): class NeatoCleaningMap(Camera): """Neato cleaning map for last clean.""" - def __init__(self, neato, robot, mapdata): + def __init__( + self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None + ) -> None: """Initialize Neato cleaning map.""" super().__init__() self.robot = robot @@ -47,24 +61,20 @@ class NeatoCleaningMap(Camera): self._mapdata = mapdata self._available = neato is not None self._robot_name = f"{self.robot.name} Cleaning Map" - self._robot_serial = self.robot.serial - self._generated_at = None - self._image_url = None - self._image = None + self._robot_serial: str = self.robot.serial + self._generated_at: str | None = None + self._image_url: str | None = None + self._image: bytes | None = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" self.update() return self._image - def update(self): + def update(self) -> None: """Check the contents of the map list.""" - if self.neato is None: - _LOGGER.error("Error while updating '%s'", self.entity_id) - self._image = None - self._image_url = None - self._available = False - return _LOGGER.debug("Running camera update for '%s'", self.entity_id) try: @@ -80,7 +90,8 @@ class NeatoCleaningMap(Camera): return image_url = None - map_data = self._mapdata[self._robot_serial]["maps"][0] + if self._mapdata: + map_data: dict[str, Any] = self._mapdata[self._robot_serial]["maps"][0] image_url = map_data["url"] if image_url == self._image_url: _LOGGER.debug( @@ -89,7 +100,7 @@ class NeatoCleaningMap(Camera): return try: - image = self.neato.download_map(image_url) + image: HTTPResponse = self.neato.download_map(image_url) except NeatoRobotException as ex: if self._available: # Print only once when available _LOGGER.error( @@ -102,33 +113,33 @@ class NeatoCleaningMap(Camera): self._image = image.read() self._image_url = image_url - self._generated_at = map_data["generated_at"] + self._generated_at = map_data.get("generated_at") self._available = True @property - def name(self): + def name(self) -> str: """Return the name of this camera.""" return self._robot_name @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID.""" return self._robot_serial @property - def available(self): + def available(self) -> bool: """Return if the robot is available.""" return self._available @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for neato robot.""" return {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}} @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" - data = {} + data: dict[str, Any] = {} if self._generated_at is not None: data[ATTR_GENERATED_AT] = self._generated_at diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 580faffe8ff..07aea0a7e9c 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -2,10 +2,13 @@ from __future__ import annotations import logging +from types import MappingProxyType +from typing import Any import voluptuous as vol -from homeassistant.const import CONF_TOKEN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import NEATO_DOMAIN @@ -23,20 +26,24 @@ class OAuth2FlowHandler( """Return logger.""" return logging.getLogger(__name__) - async def async_step_user(self, user_input: dict | None = None) -> dict: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Create an entry for the flow.""" current_entries = self._async_current_entries() - if current_entries and CONF_TOKEN in current_entries[0].data: + if self.source != SOURCE_REAUTH and current_entries: # Already configured return self.async_abort(reason="already_configured") return await super().async_step_user(user_input=user_input) - async def async_step_reauth(self, data) -> dict: + async def async_step_reauth(self, data: MappingProxyType[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input: dict | None = None) -> dict: + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm reauth upon migration of old entries.""" if user_input is None: return self.async_show_form( @@ -44,10 +51,10 @@ class OAuth2FlowHandler( ) return await self.async_step_user() - async def async_oauth_create_entry(self, data: dict) -> dict: + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow. Update an entry if one already exist.""" current_entries = self._async_current_entries() - if current_entries and CONF_TOKEN not in current_entries[0].data: + if self.source == SOURCE_REAUTH and current_entries: # Update entry self.hass.config_entries.async_update_entry( current_entries[0], title=self.flow_impl.name, data=data diff --git a/homeassistant/components/neato/hub.py b/homeassistant/components/neato/hub.py new file mode 100644 index 00000000000..cb639de4acb --- /dev/null +++ b/homeassistant/components/neato/hub.py @@ -0,0 +1,49 @@ +"""Support for Neato botvac connected vacuum cleaners.""" +from datetime import timedelta +import logging + +from pybotvac import Account +from urllib3.response import HTTPResponse + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import Throttle + +from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS + +_LOGGER = logging.getLogger(__name__) + + +class NeatoHub: + """A My Neato hub wrapper class.""" + + def __init__(self, hass: HomeAssistant, neato: Account) -> None: + """Initialize the Neato hub.""" + self._hass = hass + self.my_neato: Account = neato + + @Throttle(timedelta(minutes=1)) + def update_robots(self) -> None: + """Update the robot states.""" + _LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS)) + self._hass.data[NEATO_ROBOTS] = self.my_neato.robots + self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps + self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps + + def download_map(self, url: str) -> HTTPResponse: + """Download a new map image.""" + map_image_data = self.my_neato.get_map_image(url) + return map_image_data + + async def async_update_entry_unique_id(self, entry: ConfigEntry) -> str: + """Update entry for unique_id.""" + + await self._hass.async_add_executor_job(self.my_neato.refresh_userdata) + unique_id: str = self.my_neato.unique_id + + if entry.unique_id == unique_id: + return unique_id + + _LOGGER.debug("Updating user unique_id for previous config entry") + self._hass.config_entries.async_update_entry(entry, unique_id=unique_id) + return unique_id diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index 014e366db46..fc751df45de 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -3,7 +3,7 @@ "name": "Neato Botvac", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/neato", - "requirements": ["pybotvac==0.0.21"], + "requirements": ["pybotvac==0.0.22"], "codeowners": ["@dshokouhi", "@Santobert"], "dependencies": ["http"], "iot_class": "cloud_polling" diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 98208698037..2d54e89bb04 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -1,11 +1,20 @@ """Support for Neato sensors.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pybotvac.exceptions import NeatoRobotException +from pybotvac.robot import Robot +from homeassistant.components.neato import NeatoHub from homeassistant.components.sensor import DEVICE_CLASS_BATTERY, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES @@ -16,10 +25,12 @@ SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES) BATTERY = "Battery" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Neato sensor using config entry.""" dev = [] - neato = hass.data.get(NEATO_LOGIN) + neato: NeatoHub = hass.data[NEATO_LOGIN] for robot in hass.data[NEATO_ROBOTS]: dev.append(NeatoSensor(neato, robot)) @@ -33,15 +44,15 @@ async def async_setup_entry(hass, entry, async_add_entities): class NeatoSensor(SensorEntity): """Neato sensor.""" - def __init__(self, neato, robot): + def __init__(self, neato: NeatoHub, robot: Robot) -> None: """Initialize Neato sensor.""" self.robot = robot - self._available = False - self._robot_name = f"{self.robot.name} {BATTERY}" - self._robot_serial = self.robot.serial - self._state = None + self._available: bool = False + self._robot_name: str = f"{self.robot.name} {BATTERY}" + self._robot_serial: str = self.robot.serial + self._state: dict[str, Any] | None = None - def update(self): + def update(self) -> None: """Update Neato Sensor.""" try: self._state = self.robot.state @@ -58,36 +69,38 @@ class NeatoSensor(SensorEntity): _LOGGER.debug("self._state=%s", self._state) @property - def name(self): + def name(self) -> str: """Return the name of this sensor.""" return self._robot_name @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID.""" return self._robot_serial @property - def device_class(self): + def device_class(self) -> str: """Return the device class.""" return DEVICE_CLASS_BATTERY @property - def available(self): + def available(self) -> bool: """Return availability.""" return self._available @property - def state(self): + def native_value(self) -> str | None: """Return the state.""" - return self._state["details"]["charge"] if self._state else None + if self._state is not None: + return str(self._state["details"]["charge"]) + return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return unit of measurement.""" return PERCENTAGE @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for neato robot.""" return {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}} diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index a3cc51b82c6..0e0d49f2b28 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -1,11 +1,19 @@ """Support for Neato Connected Vacuums switches.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pybotvac.exceptions import NeatoRobotException +from pybotvac.robot import Robot +from homeassistant.components.neato import NeatoHub +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, ToggleEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES @@ -18,10 +26,13 @@ SWITCH_TYPE_SCHEDULE = "schedule" SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]} -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 Neato switch with config entry.""" dev = [] - neato = hass.data.get(NEATO_LOGIN) + neato: NeatoHub = hass.data[NEATO_LOGIN] + for robot in hass.data[NEATO_ROBOTS]: for type_name in SWITCH_TYPES: dev.append(NeatoConnectedSwitch(neato, robot, type_name)) @@ -36,18 +47,18 @@ async def async_setup_entry(hass, entry, async_add_entities): class NeatoConnectedSwitch(ToggleEntity): """Neato Connected Switches.""" - def __init__(self, neato, robot, switch_type): + def __init__(self, neato: NeatoHub, robot: Robot, switch_type: str) -> None: """Initialize the Neato Connected switches.""" self.type = switch_type self.robot = robot self._available = False self._robot_name = f"{self.robot.name} {SWITCH_TYPES[self.type][0]}" - self._state = None - self._schedule_state = None + self._state: dict[str, Any] | None = None + self._schedule_state: str | None = None self._clean_state = None - self._robot_serial = self.robot.serial + self._robot_serial: str = self.robot.serial - def update(self): + def update(self) -> None: """Update the states of Neato switches.""" _LOGGER.debug("Running Neato switch update for '%s'", self.entity_id) try: @@ -65,7 +76,7 @@ class NeatoConnectedSwitch(ToggleEntity): _LOGGER.debug("self._state=%s", self._state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) - if self._state["details"]["isScheduleEnabled"]: + if self._state is not None and self._state["details"]["isScheduleEnabled"]: self._schedule_state = STATE_ON else: self._schedule_state = STATE_OFF @@ -74,34 +85,33 @@ class NeatoConnectedSwitch(ToggleEntity): ) @property - def name(self): + def name(self) -> str: """Return the name of the switch.""" return self._robot_name @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return self._robot_serial @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" - if self.type == SWITCH_TYPE_SCHEDULE: - if self._schedule_state == STATE_ON: - return True - return False + return bool( + self.type == SWITCH_TYPE_SCHEDULE and self._schedule_state == STATE_ON + ) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for neato robot.""" return {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}} - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self.type == SWITCH_TYPE_SCHEDULE: try: @@ -111,7 +121,7 @@ class NeatoConnectedSwitch(ToggleEntity): "Neato switch connection error '%s': %s", self.entity_id, ex ) - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" if self.type == SWITCH_TYPE_SCHEDULE: try: diff --git a/homeassistant/components/neato/translations/en_GB.json b/homeassistant/components/neato/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/neato/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index b6cf43a6a3e..527cd4dce23 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -1,7 +1,11 @@ """Support for Neato Connected Vacuums.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any +from pybotvac import Robot from pybotvac.exceptions import NeatoRobotException import voluptuous as vol @@ -24,9 +28,14 @@ from homeassistant.components.vacuum import ( SUPPORT_STOP, StateVacuumEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import NeatoHub from .const import ( ACTION, ALERTS, @@ -72,12 +81,14 @@ ATTR_CATEGORY = "category" ATTR_ZONE = "zone" -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 Neato vacuum with config entry.""" dev = [] - neato = hass.data.get(NEATO_LOGIN) - mapdata = hass.data.get(NEATO_MAP_DATA) - persistent_maps = hass.data.get(NEATO_PERSISTENT_MAPS) + neato: NeatoHub = hass.data[NEATO_LOGIN] + mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA) + persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS) for robot in hass.data[NEATO_ROBOTS]: dev.append(NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps)) @@ -105,33 +116,39 @@ async def async_setup_entry(hass, entry, async_add_entities): class NeatoConnectedVacuum(StateVacuumEntity): """Representation of a Neato Connected Vacuum.""" - def __init__(self, neato, robot, mapdata, persistent_maps): + def __init__( + self, + neato: NeatoHub, + robot: Robot, + mapdata: dict[str, Any] | None, + persistent_maps: dict[str, Any] | None, + ) -> None: """Initialize the Neato Connected Vacuum.""" self.robot = robot - self._available = neato is not None + self._available: bool = neato is not None self._mapdata = mapdata - self._name = f"{self.robot.name}" - self._robot_has_map = self.robot.has_persistent_maps + self._name: str = f"{self.robot.name}" + self._robot_has_map: bool = self.robot.has_persistent_maps self._robot_maps = persistent_maps - self._robot_serial = self.robot.serial - self._status_state = None - self._clean_state = None - self._state = None - self._clean_time_start = None - self._clean_time_stop = None - self._clean_area = None - self._clean_battery_start = None - self._clean_battery_end = None - self._clean_susp_charge_count = None - self._clean_susp_time = None - self._clean_pause_time = None - self._clean_error_time = None - self._launched_from = None - self._battery_level = None - self._robot_boundaries = [] - self._robot_stats = None + self._robot_serial: str = self.robot.serial + self._status_state: str | None = None + self._clean_state: str | None = None + self._state: dict[str, Any] | None = None + self._clean_time_start: str | None = None + self._clean_time_stop: str | None = None + self._clean_area: float | None = None + self._clean_battery_start: int | None = None + self._clean_battery_end: int | None = None + self._clean_susp_charge_count: int | None = None + self._clean_susp_time: int | None = None + self._clean_pause_time: int | None = None + self._clean_error_time: int | None = None + self._launched_from: str | None = None + self._battery_level: int | None = None + self._robot_boundaries: list = [] + self._robot_stats: dict[str, Any] | None = None - def update(self): + def update(self) -> None: """Update the states of Neato Vacuums.""" _LOGGER.debug("Running Neato Vacuums update for '%s'", self.entity_id) try: @@ -151,6 +168,8 @@ class NeatoConnectedVacuum(StateVacuumEntity): self._available = False return + if self._state is None: + return self._available = True _LOGGER.debug("self._state=%s", self._state) if "alert" in self._state: @@ -198,10 +217,12 @@ class NeatoConnectedVacuum(StateVacuumEntity): self._battery_level = self._state["details"]["charge"] - if not self._mapdata.get(self._robot_serial, {}).get("maps", []): + if self._mapdata is None or not self._mapdata.get(self._robot_serial, {}).get( + "maps", [] + ): return - mapdata = self._mapdata[self._robot_serial]["maps"][0] + mapdata: dict[str, Any] = self._mapdata[self._robot_serial]["maps"][0] self._clean_time_start = mapdata["start_at"] self._clean_time_stop = mapdata["end_at"] self._clean_area = mapdata["cleaned_area"] @@ -215,10 +236,11 @@ class NeatoConnectedVacuum(StateVacuumEntity): if ( self._robot_has_map + and self._state and self._state["availableServices"]["maps"] != "basic-1" - and self._robot_maps[self._robot_serial] + and self._robot_maps ): - allmaps = self._robot_maps[self._robot_serial] + allmaps: dict = self._robot_maps[self._robot_serial] _LOGGER.debug( "Found the following maps for '%s': %s", self.entity_id, allmaps ) @@ -249,44 +271,44 @@ class NeatoConnectedVacuum(StateVacuumEntity): ) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def supported_features(self): + def supported_features(self) -> int: """Flag vacuum cleaner robot features that are supported.""" return SUPPORT_NEATO @property - def battery_level(self): + def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" return self._battery_level @property - def available(self): + def available(self) -> bool: """Return if the robot is available.""" return self._available @property - def icon(self): + def icon(self) -> str: """Return neato specific icon.""" return "mdi:robot-vacuum-variant" @property - def state(self): + def state(self) -> str | None: """Return the status of the vacuum cleaner.""" return self._clean_state @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return self._robot_serial @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" - data = {} + data: dict[str, Any] = {} if self._status_state is not None: data[ATTR_STATUS] = self._status_state @@ -314,28 +336,32 @@ class NeatoConnectedVacuum(StateVacuumEntity): return data @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for neato robot.""" - info = {"identifiers": {(NEATO_DOMAIN, self._robot_serial)}, "name": self._name} + info: DeviceInfo = { + "identifiers": {(NEATO_DOMAIN, self._robot_serial)}, + "name": self._name, + } if self._robot_stats: info["manufacturer"] = self._robot_stats["battery"]["vendor"] info["model"] = self._robot_stats["model"] info["sw_version"] = self._robot_stats["firmware"] return info - def start(self): + def start(self) -> None: """Start cleaning or resume cleaning.""" - try: - if self._state["state"] == 1: - self.robot.start_cleaning() - elif self._state["state"] == 3: - self.robot.resume_cleaning() - except NeatoRobotException as ex: - _LOGGER.error( - "Neato vacuum connection error for '%s': %s", self.entity_id, ex - ) + if self._state: + try: + if self._state["state"] == 1: + self.robot.start_cleaning() + elif self._state["state"] == 3: + self.robot.resume_cleaning() + except NeatoRobotException as ex: + _LOGGER.error( + "Neato vacuum connection error for '%s': %s", self.entity_id, ex + ) - def pause(self): + def pause(self) -> None: """Pause the vacuum.""" try: self.robot.pause_cleaning() @@ -344,7 +370,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def return_to_base(self, **kwargs): + def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" try: if self._clean_state == STATE_CLEANING: @@ -356,7 +382,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def stop(self, **kwargs): + def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" try: self.robot.stop_cleaning() @@ -365,7 +391,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def locate(self, **kwargs): + def locate(self, **kwargs: Any) -> None: """Locate the robot by making it emit a sound.""" try: self.robot.locate() @@ -374,7 +400,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def clean_spot(self, **kwargs): + def clean_spot(self, **kwargs: Any) -> None: """Run a spot cleaning starting from the base.""" try: self.robot.start_spot_cleaning() @@ -383,7 +409,9 @@ class NeatoConnectedVacuum(StateVacuumEntity): "Neato vacuum connection error for '%s': %s", self.entity_id, ex ) - def neato_custom_cleaning(self, mode, navigation, category, zone=None): + def neato_custom_cleaning( + self, mode: str, navigation: str, category: str, zone: str | None = None + ) -> None: """Zone cleaning service call.""" boundary_id = None if zone is not None: diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index de8a85f44fd..8cbe7b1f803 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -118,7 +118,7 @@ class NSDepartureSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index b999b2e94e0..ff340d38424 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -27,6 +27,7 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, ) +from homeassistant.helpers.typing import ConfigType from . import api, config_flow from .const import DATA_SDM, DATA_SUBSCRIBER, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN @@ -69,7 +70,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["sensor", "camera", "climate"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 5f5fdbc8d93..242c6147201 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -180,7 +180,9 @@ class NestCamera(Camera): self._device.add_update_listener(self.async_write_ha_state) ) - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" # Returns the snapshot of the last event for ~30 seconds after the event active_event_image = await self._async_active_event_image() diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 6278547f216..383c6d22258 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -40,7 +40,7 @@ class NestDeviceInfo: ) @property - def device_name(self) -> str: + def device_name(self) -> str | None: """Return the name of the physical device that includes the sensor.""" if InfoTrait.NAME in self._device.traits: trait: InfoTrait = self._device.traits[InfoTrait.NAME] @@ -56,11 +56,9 @@ class NestDeviceInfo: return self.device_model @property - def device_model(self) -> str: + def device_model(self) -> str | None: """Return device model information.""" # The API intentionally returns minimal information about specific # devices, instead relying on traits, but we can infer a generic model # name based on the type - if self._device.type in DEVICE_TYPE_MAP: - return DEVICE_TYPE_MAP[self._device.type] - return "Unknown" + return DEVICE_TYPE_MAP.get(self._device.type) diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index 980d9726467..619f6a3fe56 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Nest.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -62,7 +64,9 @@ async def async_get_device_trigger_types( return trigger_types -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for a Nest device.""" nest_device_id = await async_get_nest_device_id(hass, device_id) if not nest_device_id: diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py index 04f7b1ac663..76ecf16b67b 100644 --- a/homeassistant/components/nest/legacy/__init__.py +++ b/homeassistant/components/nest/legacy/__init__.py @@ -9,6 +9,7 @@ from nest.nest import APIError, AuthorizationError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -17,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity @@ -96,7 +97,7 @@ def nest_update_event_broker(hass, nest): _LOGGER.debug("Stop listening for nest.update_event") -async def async_setup_legacy(hass, config) -> bool: +async def async_setup_legacy(hass: HomeAssistant, config: dict) -> bool: """Set up Nest components using the legacy nest API.""" if DOMAIN not in config: return True @@ -122,7 +123,7 @@ async def async_setup_legacy(hass, config) -> bool: return True -async def async_setup_legacy_entry(hass, entry) -> bool: +async def async_setup_legacy_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nest from legacy config entry.""" nest = Nest(access_token=entry.data["tokens"]["access_token"]) diff --git a/homeassistant/components/nest/legacy/camera.py b/homeassistant/components/nest/legacy/camera.py index 77629e4dcff..3ef0089d2bc 100644 --- a/homeassistant/components/nest/legacy/camera.py +++ b/homeassistant/components/nest/legacy/camera.py @@ -1,4 +1,6 @@ """Support for Nest Cameras.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -131,7 +133,9 @@ class NestCamera(Camera): def _ready_for_snapshot(self, now): return self._next_snapshot_at is None or now > self._next_snapshot_at - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" now = utcnow() if self._ready_for_snapshot(now): diff --git a/homeassistant/components/nest/legacy/sensor.py b/homeassistant/components/nest/legacy/sensor.py index 0939e925b43..f2c6670bf8b 100644 --- a/homeassistant/components/nest/legacy/sensor.py +++ b/homeassistant/components/nest/legacy/sensor.py @@ -154,12 +154,12 @@ class NestBasicSensor(NestSensorDevice, SensorEntity): """Representation a basic Nest sensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -189,12 +189,12 @@ class NestTempSensor(NestSensorDevice, SensorEntity): """Representation of a Nest Temperature sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 6c9462e43db..5b078393d1e 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["ffmpeg", "http"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.3.5"], + "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.3.6"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 42614af8c40..0034acff3af 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -95,13 +95,13 @@ class TemperatureSensor(SensorBase): return f"{self._device_info.device_name} Temperature" @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] return trait.ambient_temperature_celsius @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @@ -126,13 +126,13 @@ class HumiditySensor(SensorBase): return f"{self._device_info.device_name} Humidity" @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" trait: HumidityTrait = self._device.traits[HumidityTrait.NAME] return trait.ambient_humidity_percent @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return PERCENTAGE diff --git a/homeassistant/components/nest/translations/da.json b/homeassistant/components/nest/translations/da.json index 054b4442506..5224e7a660d 100644 --- a/homeassistant/components/nest/translations/da.json +++ b/homeassistant/components/nest/translations/da.json @@ -14,7 +14,7 @@ "flow_impl": "Udbyder" }, "description": "V\u00e6lg hvilken godkendelsesudbyder du vil godkende med Nest.", - "title": "Godkendelsesudbyder" + "title": "Identitetsudbyder" }, "link": { "data": { diff --git a/homeassistant/components/nest/translations/en_GB.json b/homeassistant/components/nest/translations/en_GB.json new file mode 100644 index 00000000000..f87b814d8cc --- /dev/null +++ b/homeassistant/components/nest/translations/en_GB.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL.", + "unknown_authorize_url_generation": "Unknown error generating an authorise URL." + }, + "step": { + "link": { + "description": "To link your Nest account, [authorise your account]({url}).\n\nAfter authorisation, copy-paste the provided PIN code below." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/lt.json b/homeassistant/components/nest/translations/lt.json new file mode 100644 index 00000000000..629b65d347d --- /dev/null +++ b/homeassistant/components/nest/translations/lt.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "unknown": "Nenumatyta klaida" + }, + "step": { + "link": { + "data": { + "code": "PIN kodas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index edb8837fd18..76a5eeb9c86 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -31,6 +31,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.typing import ConfigType from . import api, config_flow from .const import ( @@ -69,7 +70,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Netatmo component.""" hass.data[DOMAIN] = { DATA_PERSONS: {}, diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 346f9e93647..4d6141e2dfb 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -168,13 +168,13 @@ class NetatmoCamera(NetatmoBase, Camera): return if data["home_id"] == self._home_id and data["camera_id"] == self._id: - if data[WEBHOOK_PUSH_TYPE] in ["NACamera-off", "NACamera-disconnection"]: + if data[WEBHOOK_PUSH_TYPE] in ("NACamera-off", "NACamera-disconnection"): self.is_streaming = False self._status = "off" - elif data[WEBHOOK_PUSH_TYPE] in [ + elif data[WEBHOOK_PUSH_TYPE] in ( "NACamera-on", WEBHOOK_NACAMERA_CONNECTION, - ]: + ): self.is_streaming = True self._status = "on" elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE: @@ -194,10 +194,14 @@ class NetatmoCamera(NetatmoBase, Camera): self.data_handler.data[self._data_classes[0]["name"]], ) - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: - return await self._data.async_get_live_snapshot(camera_id=self._id) + return cast( + bytes, await self._data.async_get_live_snapshot(camera_id=self._id) + ) except ( aiohttp.ClientPayloadError, aiohttp.ContentTypeError, diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index ccc5816a28b..e71b6939982 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any, cast import pyatmo import voluptuous as vol @@ -58,6 +58,7 @@ from .data_handler import ( HOMESTATUS_DATA_CLASS_NAME, NetatmoDataHandler, ) +from .helper import get_all_home_ids, update_climate_schedules from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -131,9 +132,6 @@ async def async_setup_entry( if not home_data or home_data.raw_data == {}: raise PlatformNotReady - if HOMEDATA_DATA_CLASS_NAME not in data_handler.data: - raise PlatformNotReady - entities = [] for home_id in get_all_home_ids(home_data): for room_id in home_data.rooms[home_id]: @@ -145,12 +143,12 @@ async def async_setup_entry( if home_status and room_id in home_status.rooms: entities.append(NetatmoThermostat(data_handler, home_id, room_id)) - hass.data[DOMAIN][DATA_SCHEDULES][home_id] = { - schedule_id: schedule_data.get("name") - for schedule_id, schedule_data in ( - data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules[home_id].items() - ) - } + hass.data[DOMAIN][DATA_SCHEDULES].update( + update_climate_schedules( + home_ids=get_all_home_ids(home_data), + schedules=data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules, + ) + ) hass.data[DOMAIN][DATA_HOMES] = { home_id: home_data.get("name") @@ -257,7 +255,8 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): assert device self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._home_id] = device.id - async def handle_event(self, event: dict) -> None: + @callback + def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] @@ -396,7 +395,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) if ( - preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] + preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE and self.hvac_mode == HVAC_MODE_HEAT ): @@ -405,7 +404,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): STATE_NETATMO_HOME, ) elif ( - preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] and self._model == NA_VALVE + preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE ): await self._home_status.async_set_room_thermpoint( self._id, @@ -413,17 +412,17 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): DEFAULT_MAX_TEMP, ) elif ( - preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] + preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self.hvac_mode == HVAC_MODE_HEAT ): await self._home_status.async_set_room_thermpoint( self._id, STATE_NETATMO_HOME ) - elif preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX]: + elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX): await self._home_status.async_set_room_thermpoint( self._id, PRESET_MAP_NETATMO[preset_mode] ) - elif preset_mode in [PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY]: + elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY): await self._home_status.async_set_thermmode(PRESET_MAP_NETATMO[preset_mode]) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) @@ -440,7 +439,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): """Return a list of available preset modes.""" return SUPPORT_PRESET - async def async_set_temperature(self, **kwargs: dict) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature for 2 hours.""" temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: @@ -590,7 +589,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return {} - async def _async_service_set_schedule(self, **kwargs: dict) -> None: + async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items(): @@ -617,14 +616,3 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): device_info: DeviceInfo = super().device_info device_info["suggested_area"] = self._room_data["name"] return device_info - - -def get_all_home_ids(home_data: pyatmo.HomeData | None) -> list[str]: - """Get all the home ids returned by NetAtmo API.""" - if home_data is None: - return [] - return [ - home_data.homes[home_id]["id"] - for home_id in home_data.homes - if "modules" in home_data.homes[home_id] - ] diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index 1bfc736d581..777b905f5d7 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Netatmo.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -84,7 +86,9 @@ async def async_validate_trigger_config( return config -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Netatmo devices.""" registry = await entity_registry.async_get_registry(hass) device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py index 7e8f32817dd..d824013ed27 100644 --- a/homeassistant/components/netatmo/helper.py +++ b/homeassistant/components/netatmo/helper.py @@ -1,7 +1,11 @@ """Helper for Netatmo integration.""" +from __future__ import annotations + from dataclasses import dataclass from uuid import UUID, uuid4 +import pyatmo + @dataclass class NetatmoArea: @@ -15,3 +19,25 @@ class NetatmoArea: mode: str show_on_map: bool uuid: UUID = uuid4() + + +def get_all_home_ids(home_data: pyatmo.HomeData | None) -> list[str]: + """Get all the home ids returned by NetAtmo API.""" + if home_data is None: + return [] + return [ + home_data.homes[home_id]["id"] + for home_id in home_data.homes + if "modules" in home_data.homes[home_id] + ] + + +def update_climate_schedules(home_ids: list[str], schedules: dict) -> dict: + """Get updated list of all climate schedules.""" + return { + home_id: { + schedule_id: schedule_data.get("name") + for schedule_id, schedule_data in schedules[home_id].items() + } + for home_id in home_ids + } diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 6fe5e84e65a..34c0d023edc 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any, cast import pyatmo @@ -141,7 +141,7 @@ class NetatmoLight(NetatmoBase, LightEntity): """Return true if light is on.""" return self._is_on - async def async_turn_on(self, **kwargs: dict) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn camera floodlight on.""" _LOGGER.debug("Turn camera '%s' on", self.name) await self._data.async_set_state( @@ -150,7 +150,7 @@ class NetatmoLight(NetatmoBase, LightEntity): floodlight="on", ) - async def async_turn_off(self, **kwargs: dict) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn camera floodlight into auto mode.""" _LOGGER.debug("Turn camera '%s' to auto mode", self.name) await self._data.async_set_state( diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 718d7e440b9..387fb8f0acc 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -13,7 +13,6 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .climate import get_all_home_ids from .const import ( DATA_HANDLER, DATA_SCHEDULES, @@ -23,6 +22,7 @@ from .const import ( SIGNAL_NAME, ) from .data_handler import HOMEDATA_DATA_CLASS_NAME, NetatmoDataHandler +from .helper import get_all_home_ids, update_climate_schedules from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -42,8 +42,12 @@ async def async_setup_entry( if not home_data or home_data.raw_data == {}: raise PlatformNotReady - if HOMEDATA_DATA_CLASS_NAME not in data_handler.data: - raise PlatformNotReady + hass.data[DOMAIN][DATA_SCHEDULES].update( + update_climate_schedules( + home_ids=get_all_home_ids(home_data), + schedules=data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules, + ) + ) entities = [ NetatmoScheduleSelect( @@ -51,8 +55,7 @@ async def async_setup_entry( home_id, list(hass.data[DOMAIN][DATA_SCHEDULES][home_id].values()), ) - for home_id in get_all_home_ids(home_data) - if home_id in hass.data[DOMAIN][DATA_SCHEDULES] + for home_id in hass.data[DOMAIN][DATA_SCHEDULES] ] _LOGGER.debug("Adding climate schedule select entities %s", entities) @@ -105,7 +108,8 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): ) ) - async def handle_event(self, event: dict) -> None: + @callback + def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 14128aefa6a..a1f7b2ac079 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -7,7 +7,11 @@ from typing import NamedTuple, cast import pyatmo -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, @@ -79,8 +83,9 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Temperature", netatmo_name="Temperature", entity_registry_enabled_default=True, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="temp_trend", @@ -93,17 +98,19 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="co2", name="CO2", netatmo_name="CO2", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, entity_registry_enabled_default=True, device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="pressure", name="Pressure", netatmo_name="Pressure", entity_registry_enabled_default=True, - unit_of_measurement=PRESSURE_MBAR, + native_unit_of_measurement=PRESSURE_MBAR, device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="pressure_trend", @@ -117,23 +124,25 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Noise", netatmo_name="Noise", entity_registry_enabled_default=True, - unit_of_measurement=SOUND_PRESSURE_DB, + native_unit_of_measurement=SOUND_PRESSURE_DB, icon="mdi:volume-high", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="humidity", name="Humidity", netatmo_name="Humidity", entity_registry_enabled_default=True, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="rain", name="Rain", netatmo_name="Rain", entity_registry_enabled_default=True, - unit_of_measurement=LENGTH_MILLIMETERS, + native_unit_of_measurement=LENGTH_MILLIMETERS, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -141,7 +150,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Rain last hour", netatmo_name="sum_rain_1", entity_registry_enabled_default=False, - unit_of_measurement=LENGTH_MILLIMETERS, + native_unit_of_measurement=LENGTH_MILLIMETERS, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -149,7 +158,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Rain today", netatmo_name="sum_rain_24", entity_registry_enabled_default=True, - unit_of_measurement=LENGTH_MILLIMETERS, + native_unit_of_measurement=LENGTH_MILLIMETERS, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -157,8 +166,9 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Battery Percent", netatmo_name="battery_percent", entity_registry_enabled_default=True, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="windangle", @@ -172,16 +182,18 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Angle", netatmo_name="WindAngle", entity_registry_enabled_default=False, - unit_of_measurement=DEGREE, + native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="windstrength", name="Wind Strength", netatmo_name="WindStrength", entity_registry_enabled_default=True, - unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="gustangle", @@ -195,16 +207,18 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Gust Angle", netatmo_name="GustAngle", entity_registry_enabled_default=False, - unit_of_measurement=DEGREE, + native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="guststrength", name="Gust Strength", netatmo_name="GustStrength", entity_registry_enabled_default=False, - unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="reachable", @@ -225,8 +239,9 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Radio Level", netatmo_name="rf_status", entity_registry_enabled_default=False, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="wifi_status", @@ -240,8 +255,9 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="Wifi Level", netatmo_name="wifi_status", entity_registry_enabled_default=False, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, ), NetatmoSensorEntityDescription( key="health_idx", @@ -502,25 +518,25 @@ class NetatmoSensor(NetatmoBase, SensorEntity): self._device_name, self._id, ) - self._attr_state = None + self._attr_native_value = None return try: state = data[self.entity_description.netatmo_name] if self.entity_description.key in {"temperature", "pressure", "sum_rain_1"}: - self._attr_state = round(state, 1) + self._attr_native_value = round(state, 1) elif self.entity_description.key in {"windangle_value", "gustangle_value"}: - self._attr_state = fix_angle(state) + self._attr_native_value = fix_angle(state) elif self.entity_description.key in {"windangle", "gustangle"}: - self._attr_state = process_angle(fix_angle(state)) + self._attr_native_value = process_angle(fix_angle(state)) elif self.entity_description.key == "rf_status": - self._attr_state = process_rf(state) + self._attr_native_value = process_rf(state) elif self.entity_description.key == "wifi_status": - self._attr_state = process_wifi(state) + self._attr_native_value = process_wifi(state) elif self.entity_description.key == "health_idx": - self._attr_state = process_health(state) + self._attr_native_value = process_health(state) else: - self._attr_state = state + self._attr_native_value = state except KeyError: if self.state: _LOGGER.debug( @@ -528,7 +544,7 @@ class NetatmoSensor(NetatmoBase, SensorEntity): self.entity_description.key, self._device_name, ) - self._attr_state = None + self._attr_native_value = None return self.async_write_ha_state() @@ -742,14 +758,14 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): self.entity_description.key, self._area_name, ) - self._attr_state = None + self._attr_native_value = None return if values := [x for x in data.values() if x is not None]: if self._mode == "avg": - self._attr_state = round(sum(values) / len(values), 1) + self._attr_native_value = round(sum(values) / len(values), 1) elif self._mode == "max": - self._attr_state = max(values) + self._attr_native_value = max(values) self._attr_available = self.state is not None self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/translations/en_GB.json b/homeassistant/components/netatmo/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/netatmo/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/he.json b/homeassistant/components/netatmo/translations/he.json index 54571f698fe..32d7ecac5f0 100644 --- a/homeassistant/components/netatmo/translations/he.json +++ b/homeassistant/components/netatmo/translations/he.json @@ -31,8 +31,15 @@ "step": { "public_weather": { "data": { + "mode": "\u05d7\u05d9\u05e9\u05d5\u05d1", "show_on_map": "\u05d4\u05e6\u05d2 \u05d1\u05de\u05e4\u05d4" } + }, + "public_weather_areas": { + "data": { + "new_area": "\u05e9\u05dd \u05d0\u05d6\u05d5\u05e8", + "weather_areas": "\u05d0\u05d6\u05d5\u05e8\u05d9 \u05de\u05d6\u05d2 \u05d0\u05d5\u05d5\u05d9\u05e8" + } } } } diff --git a/homeassistant/components/netatmo/translations/hu.json b/homeassistant/components/netatmo/translations/hu.json index 0e6536bb0ad..48f084f84c2 100644 --- a/homeassistant/components/netatmo/translations/hu.json +++ b/homeassistant/components/netatmo/translations/hu.json @@ -41,14 +41,24 @@ "step": { "public_weather": { "data": { - "area_name": "A ter\u00fclet neve" - } + "area_name": "A ter\u00fclet neve", + "lat_ne": "Sz\u00e9less\u00e9g \u00c9szakkeleti sarok", + "lat_sw": "Sz\u00e9less\u00e9g D\u00e9lnyugati sarok", + "lon_ne": "Hossz\u00fas\u00e1g \u00c9szakkeleti sarok", + "lon_sw": "Hossz\u00fas\u00e1g D\u00e9lnyugati sarok", + "mode": "Sz\u00e1m\u00edt\u00e1s", + "show_on_map": "Mutasd a t\u00e9rk\u00e9pen" + }, + "description": "\u00c1ll\u00edtson be egy nyilv\u00e1nos id\u0151j\u00e1r\u00e1s-\u00e9rz\u00e9kel\u0151t egy ter\u00fclethez.", + "title": "Netatmo nyilv\u00e1nos id\u0151j\u00e1r\u00e1s-\u00e9rz\u00e9kel\u0151" }, "public_weather_areas": { "data": { "new_area": "Ter\u00fclet neve", "weather_areas": "Id\u0151j\u00e1r\u00e1si ter\u00fcletek" - } + }, + "description": "\u00c1ll\u00edtsa be a nyilv\u00e1nos id\u0151j\u00e1r\u00e1s-\u00e9rz\u00e9kel\u0151ket.", + "title": "Netatmo nyilv\u00e1nos id\u0151j\u00e1r\u00e1s-\u00e9rz\u00e9kel\u0151" } } } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 21e4cd1b005..d1fa87a6e5d 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -117,7 +117,7 @@ class NetdataSensor(SensorEntity): return f"{self._name} {self._sensor_name}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -127,7 +127,7 @@ class NetdataSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the resources.""" return self._state @@ -162,7 +162,7 @@ class NetdataAlarms(SensorEntity): return f"{self._name} Alarms" @property - def state(self): + def native_value(self): """Return the state of the resources.""" return self._state diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index c8f07301e98..0996ad3d315 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -37,7 +37,7 @@ class LTESensor(LTEEntity, SensorEntity): """Base LTE sensor entity.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return SENSOR_UNITS[self.sensor_type] @@ -46,7 +46,7 @@ class SMSUnreadSensor(LTESensor): """Unread SMS sensor entity.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return sum(1 for x in self.modem_data.data.sms if x.unread) @@ -55,7 +55,7 @@ class SMSTotalSensor(LTESensor): """Total SMS sensor entity.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return len(self.modem_data.data.sms) @@ -64,7 +64,7 @@ class UsageSensor(LTESensor): """Data usage sensor entity.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return round(self.modem_data.data.usage / 1024 ** 2, 1) @@ -73,6 +73,6 @@ class GenericSensor(LTESensor): """Sensor entity with raw state.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return getattr(self.modem_data.data, self.sensor_type) diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index c39b1598c89..88da77cbf90 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -1,7 +1,10 @@ """The Netio switch component.""" +from __future__ import annotations + from collections import namedtuple from datetime import timedelta import logging +from typing import Any from pynetio import Netio import voluptuous as vol @@ -29,8 +32,8 @@ CONF_OUTLETS = "outlets" DEFAULT_PORT = 1234 DEFAULT_USERNAME = "admin" -Device = namedtuple("device", ["netio", "entities"]) -DEVICES = {} +Device = namedtuple("Device", ["netio", "entities"]) +DEVICES: dict[str, Any] = {} MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 48903d145e7..a7dffad7084 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -1,13 +1,14 @@ """The Network Configuration integration.""" from __future__ import annotations +from ipaddress import IPv4Address, IPv6Address import logging import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -45,6 +46,35 @@ async def async_get_source_ip(hass: HomeAssistant, target_ip: str) -> str: return source_ip if source_ip in all_ipv4s else all_ipv4s[0] +@bind_hass +async def async_get_enabled_source_ips( + hass: HomeAssistant, +) -> list[IPv4Address | IPv6Address]: + """Build the list of enabled source ips.""" + adapters = await async_get_adapters(hass) + sources: list[IPv4Address | IPv6Address] = [] + for adapter in adapters: + if not adapter["enabled"]: + continue + if adapter["ipv4"]: + sources.extend(IPv4Address(ipv4["address"]) for ipv4 in adapter["ipv4"]) + if adapter["ipv6"]: + # With python 3.9 add scope_ids can be + # added by enumerating adapter["ipv6"]s + # IPv6Address(f"::%{ipv6['scope_id']}") + sources.extend(IPv6Address(ipv6["address"]) for ipv6 in adapter["ipv6"]) + + return sources + + +@callback +def async_only_default_interface_enabled(adapters: list[Adapter]) -> bool: + """Check to see if any non-default adapter is enabled.""" + return not any( + adapter["enabled"] and not adapter["default"] for adapter in adapters + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index d74d6338c8b..37113dde8b7 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -149,12 +149,12 @@ class NeurioEnergy(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index a14931e41ee..6e44b8c9883 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -182,7 +182,7 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): return self._class @property - def state(self): + def native_value(self): """Return the state of the sensor.""" val = getattr(self._thermostat, self._call)() if self._modifier: @@ -192,7 +192,7 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): return val @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement @@ -230,7 +230,7 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): return self._class @property - def state(self): + def native_value(self): """Return the state of the sensor.""" val = getattr(self._zone, self._call)() if self._modifier: @@ -240,6 +240,6 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): return val @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 71001bfc52c..3343e24b277 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -3,6 +3,6 @@ "name": "NextBus", "documentation": "https://www.home-assistant.io/integrations/nextbus", "codeowners": ["@vividboarder"], - "requirements": ["py_nextbusnext==0.1.4"], + "requirements": ["py_nextbusnext==0.1.5"], "iot_class": "local_polling" } diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index fb03bcd25b5..f9df0d60412 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -146,7 +146,7 @@ class NextBusDepartureSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return current state of the sensor.""" return self._state diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 5cd02f124e9..6a2d106bb10 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -34,7 +34,7 @@ class NextcloudSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state for this sensor.""" return self._state diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 35aecdb6916..92bb492bf7d 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -7,13 +7,14 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN PLATFORMS = [NOTIFY] -async def async_setup(hass: HomeAssistant, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NFAndroidTV component.""" hass.data.setdefault(DOMAIN, {}) # Iterate all entries for notify to only get nfandroidtv diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index 8cc1b0031f7..0f15b152038 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -201,7 +201,7 @@ class NFAndroidTVNotificationService(BaseNotificationService): if local_path is not None: # Check whether path is whitelisted in configuration.yaml if self.is_allowed_path(local_path): - return open(local_path, "rb") # pylint: disable=consider-using-with + return open(local_path, "rb") _LOGGER.warning("'%s' is not secure to load data from!", local_path) else: _LOGGER.warning("Neither URL nor local path found in params!") diff --git a/homeassistant/components/nfandroidtv/translations/es.json b/homeassistant/components/nfandroidtv/translations/es.json new file mode 100644 index 00000000000..e99ce545b74 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/es.json @@ -0,0 +1,10 @@ +{ + "config": { + "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.", + "title": "Notificaciones para Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/hu.json b/homeassistant/components/nfandroidtv/translations/hu.json new file mode 100644 index 00000000000..e7dea95e4d0 --- /dev/null +++ b/homeassistant/components/nfandroidtv/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", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "host": "H\u00e1zigazda", + "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.", + "title": "\u00c9rtes\u00edt\u00e9sek Android TV / Fire TV eset\u00e9n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/no.json b/homeassistant/components/nfandroidtv/translations/no.json new file mode 100644 index 00000000000..e8aea574c96 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn" + }, + "description": "Denne integrasjonen krever Notifications for Android TV -appen. \n\n For Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\n For Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\n Du b\u00f8r konfigurere enten DHCP -reservasjon p\u00e5 ruteren din (se brukerh\u00e5ndboken til ruteren din) eller en statisk IP -adresse p\u00e5 enheten. Hvis ikke, vil enheten til slutt bli utilgjengelig.", + "title": "Varsler for Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 183755298d6..1b37fa8da7c 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -58,7 +58,7 @@ class NightscoutSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -68,7 +68,7 @@ class NightscoutSensor(SensorEntity): return self._available @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 936d607a84e..4074cd47f50 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -50,12 +50,12 @@ class LeafBatterySensor(LeafEntity, SensorEntity): return DEVICE_CLASS_BATTERY @property - def state(self): + def native_value(self): """Battery state percentage.""" return round(self.car.data[DATA_BATTERY]) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Battery state measured in percentage.""" return PERCENTAGE @@ -89,7 +89,7 @@ class LeafRangeSensor(LeafEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Battery range in miles or kms.""" if self._ac_on: ret = self.car.data[DATA_RANGE_AC] @@ -102,7 +102,7 @@ class LeafRangeSensor(LeafEntity, SensorEntity): return round(ret) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Battery range unit.""" if not self.car.hass.config.units.is_metric or self.car.force_miles: return LENGTH_MILES diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index da699caaa73..21469f197f4 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -1 +1,414 @@ -"""The nmap_tracker component.""" +"""The Nmap Tracker integration.""" +from __future__ import annotations + +import asyncio +import contextlib +from dataclasses import dataclass +from datetime import datetime, timedelta +from functools import partial +import logging +from typing import Final + +import aiohttp +from getmac import get_mac_address +from mac_vendor_lookup import AsyncMacLookup +from nmap import PortScanner, PortScannerError + +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DOMAIN, + NMAP_TRACKED_DEVICES, + PLATFORMS, + TRACKER_SCAN_INTERVAL, +) + +# Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' +NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true" +MAX_SCAN_ATTEMPTS: Final = 16 + + +def short_hostname(hostname: str) -> str: + """Return the first part of the hostname.""" + return hostname.split(".")[0] + + +def human_readable_name(hostname: str, vendor: str, mac_address: str) -> str: + """Generate a human readable name.""" + if hostname: + return short_hostname(hostname) + if vendor: + return f"{vendor} {mac_address[-8:]}" + return f"Nmap Tracker {mac_address}" + + +@dataclass +class NmapDevice: + """Class for keeping track of an nmap tracked device.""" + + mac_address: str + hostname: str + name: str + ipv4: str + manufacturer: str + reason: str + last_update: datetime + first_offline: datetime | None + + +class NmapTrackedDevices: + """Storage class for all nmap trackers.""" + + def __init__(self) -> None: + """Initialize the data.""" + self.tracked: dict[str, NmapDevice] = {} + self.ipv4_last_mac: dict[str, str] = {} + self.config_entry_owner: dict[str, str] = {} + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Nmap Tracker from a config entry.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) + scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) + await scanner.async_setup() + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + _async_untrack_devices(hass, entry) + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +@callback +def _async_untrack_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove tracking for devices owned by this config entry.""" + devices = hass.data[DOMAIN][NMAP_TRACKED_DEVICES] + remove_mac_addresses = [ + mac_address + for mac_address, entry_id in devices.config_entry_owner.items() + if entry_id == entry.entry_id + ] + for mac_address in remove_mac_addresses: + if device := devices.tracked.pop(mac_address, None): + devices.ipv4_last_mac.pop(device.ipv4, None) + del devices.config_entry_owner[mac_address] + + +def signal_device_update(mac_address) -> str: + """Signal specific per nmap tracker entry to signal updates in device.""" + return f"{DOMAIN}-device-update-{mac_address}" + + +class NmapDeviceScanner: + """This class scans for devices using nmap.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, devices: NmapTrackedDevices + ) -> None: + """Initialize the scanner.""" + self.devices = devices + self.home_interval = None + self.consider_home = DEFAULT_CONSIDER_HOME + + self._hass = hass + self._entry = entry + + self._scan_lock = None + self._stopping = False + self._scanner = None + + self._entry_id = entry.entry_id + self._hosts = None + self._options = None + self._exclude = None + self._scan_interval = None + + self._known_mac_addresses: dict[str, str] = {} + self._finished_first_scan = False + self._last_results: list[NmapDevice] = [] + self._mac_vendor_lookup = None + + async def async_setup(self): + """Set up the tracker.""" + config = self._entry.options + self._scan_interval = timedelta( + seconds=config.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL) + ) + hosts_list = cv.ensure_list_csv(config[CONF_HOSTS]) + self._hosts = [host for host in hosts_list if host != ""] + excludes_list = cv.ensure_list_csv(config[CONF_EXCLUDE]) + self._exclude = [exclude for exclude in excludes_list if exclude != ""] + self._options = config[CONF_OPTIONS] + self.home_interval = timedelta( + minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) + ) + if config.get(CONF_CONSIDER_HOME): + self.consider_home = timedelta( + seconds=cv.positive_float(config[CONF_CONSIDER_HOME]) + ) + self._scan_lock = asyncio.Lock() + if self._hass.state == CoreState.running: + await self._async_start_scanner() + return + + self._entry.async_on_unload( + self._hass.bus.async_listen( + EVENT_HOMEASSISTANT_STARTED, self._async_start_scanner + ) + ) + registry = er.async_get(self._hass) + self._known_mac_addresses = { + entry.unique_id: entry.original_name + for entry in registry.entities.values() + if entry.config_entry_id == self._entry_id + } + + @property + def signal_device_new(self) -> str: + """Signal specific per nmap tracker entry to signal new device.""" + return f"{DOMAIN}-device-new-{self._entry_id}" + + @property + def signal_device_missing(self) -> str: + """Signal specific per nmap tracker entry to signal a missing device.""" + return f"{DOMAIN}-device-missing-{self._entry_id}" + + @callback + def _async_get_vendor(self, mac_address): + """Lookup the vendor.""" + oui = self._mac_vendor_lookup.sanitise(mac_address)[:6] + return self._mac_vendor_lookup.prefixes.get(oui) + + @callback + def _async_stop(self): + """Stop the scanner.""" + self._stopping = True + + async def _async_start_scanner(self, *_): + """Start the scanner.""" + self._entry.async_on_unload(self._async_stop) + self._entry.async_on_unload( + async_track_time_interval( + self._hass, + self._async_scan_devices, + self._scan_interval, + ) + ) + self._mac_vendor_lookup = AsyncMacLookup() + with contextlib.suppress((asyncio.TimeoutError, aiohttp.ClientError)): + # We don't care if this fails since it only + # improves the data when we don't have it from nmap + await self._mac_vendor_lookup.load_vendors() + self._hass.async_create_task(self._async_scan_devices()) + + def _build_options(self): + """Build the command line and strip out last results that do not need to be updated.""" + options = self._options + if self.home_interval: + boundary = dt_util.now() - self.home_interval + last_results = [ + device for device in self._last_results if device.last_update > boundary + ] + if last_results: + exclude_hosts = self._exclude + [device.ipv4 for device in last_results] + else: + exclude_hosts = self._exclude + else: + last_results = [] + exclude_hosts = self._exclude + if exclude_hosts: + options += f" --exclude {','.join(exclude_hosts)}" + # Report reason + if "--reason" not in options: + options += " --reason" + # Report down hosts + if "-v" not in options: + options += " -v" + self._last_results = last_results + return options + + async def _async_scan_devices(self, *_): + """Scan devices and dispatch.""" + if self._scan_lock.locked(): + _LOGGER.debug( + "Nmap scanning is taking longer than the scheduled interval: %s", + TRACKER_SCAN_INTERVAL, + ) + return + + async with self._scan_lock: + try: + await self._async_run_nmap_scan() + except PortScannerError as ex: + _LOGGER.error("Nmap scanning failed: %s", ex) + + if not self._finished_first_scan: + self._finished_first_scan = True + await self._async_mark_missing_devices_as_not_home() + + async def _async_mark_missing_devices_as_not_home(self): + # After all config entries have finished their first + # scan we mark devices that were not found as not_home + # from unavailable + now = dt_util.now() + for mac_address, original_name in self._known_mac_addresses.items(): + if mac_address in self.devices.tracked: + continue + self.devices.config_entry_owner[mac_address] = self._entry_id + self.devices.tracked[mac_address] = NmapDevice( + mac_address, + None, + original_name, + None, + self._async_get_vendor(mac_address), + "Device not found in initial scan", + now, + 1, + ) + async_dispatcher_send(self._hass, self.signal_device_missing, mac_address) + + def _run_nmap_scan(self): + """Run nmap and return the result.""" + options = self._build_options() + if not self._scanner: + self._scanner = PortScanner() + _LOGGER.debug("Scanning %s with args: %s", self._hosts, options) + for attempt in range(MAX_SCAN_ATTEMPTS): + try: + result = self._scanner.scan( + hosts=" ".join(self._hosts), + arguments=options, + timeout=TRACKER_SCAN_INTERVAL * 10, + ) + break + except PortScannerError as ex: + if attempt < (MAX_SCAN_ATTEMPTS - 1) and NMAP_TRANSIENT_FAILURE in str( + ex + ): + _LOGGER.debug("Nmap saw transient error %s", NMAP_TRANSIENT_FAILURE) + continue + raise + _LOGGER.debug( + "Finished scanning %s with args: %s", + self._hosts, + options, + ) + return result + + @callback + def _async_device_offline(self, ipv4: str, reason: str, now: datetime) -> None: + """Mark an IP offline.""" + if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)): + return + if not (device := self.devices.tracked.get(formatted_mac)): + # Device was unloaded + return + if not device.first_offline: + _LOGGER.debug( + "Setting first_offline for %s (%s) to: %s", ipv4, formatted_mac, now + ) + device.first_offline = now + return + if device.first_offline + self.consider_home > now: + _LOGGER.debug( + "Device %s (%s) has NOT been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) + return + _LOGGER.debug( + "Device %s (%s) has been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) + device.reason = reason + async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False) + del self.devices.ipv4_last_mac[ipv4] + + async def _async_run_nmap_scan(self): + """Scan the network for devices and dispatch events.""" + result = await self._hass.async_add_executor_job(self._run_nmap_scan) + if self._stopping: + return + + devices = self.devices + entry_id = self._entry_id + now = dt_util.now() + for ipv4, info in result["scan"].items(): + status = info["status"] + reason = status["reason"] + if status["state"] != "up": + self._async_device_offline(ipv4, reason, now) + continue + # Mac address only returned if nmap ran as root + mac = info["addresses"].get( + "mac" + ) or await self._hass.async_add_executor_job( + partial(get_mac_address, ip=ipv4) + ) + if mac is None: + self._async_device_offline(ipv4, "No MAC address found", now) + _LOGGER.info("No MAC address found for %s", ipv4) + continue + + formatted_mac = format_mac(mac) + if ( + devices.config_entry_owner.setdefault(formatted_mac, entry_id) + != entry_id + ): + continue + + hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 + vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) + name = human_readable_name(hostname, vendor, mac) + device = NmapDevice( + formatted_mac, hostname, name, ipv4, vendor, reason, now, None + ) + + new = formatted_mac not in devices.tracked + devices.tracked[formatted_mac] = device + devices.ipv4_last_mac[ipv4] = formatted_mac + self._last_results.append(device) + + if new: + async_dispatcher_send(self._hass, self.signal_device_new, formatted_mac) + else: + async_dispatcher_send( + self._hass, signal_device_update(formatted_mac), True + ) diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py new file mode 100644 index 00000000000..c9e9706e4ba --- /dev/null +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -0,0 +1,224 @@ +"""Config flow for Nmap Tracker integration.""" +from __future__ import annotations + +from ipaddress import ip_address, ip_network, summarize_address_range +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import network +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) +from homeassistant.components.network.const import MDNS_TARGET_IP +from homeassistant.config_entries import ConfigEntry, OptionsFlow +from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) + +MAX_SCAN_INTERVAL = 3600 +MAX_CONSIDER_HOME = MAX_SCAN_INTERVAL * 6 +DEFAULT_NETWORK_PREFIX = 24 + + +async def async_get_network(hass: HomeAssistant) -> str: + """Search adapters for the network.""" + # We want the local ip that is most likely to be + # on the LAN and not the WAN so we use MDNS_TARGET_IP + local_ip = await network.async_get_source_ip(hass, MDNS_TARGET_IP) + network_prefix = DEFAULT_NETWORK_PREFIX + for adapter in await network.async_get_adapters(hass): + for ipv4 in adapter["ipv4"]: + if ipv4["address"] == local_ip: + network_prefix = ipv4["network_prefix"] + break + return str(ip_network(f"{local_ip}/{network_prefix}", False)) + + +def _normalize_ips_and_network(hosts_str: str) -> list[str] | None: + """Check if a list of hosts are all ips or ip networks.""" + + normalized_hosts = [] + hosts = [host for host in cv.ensure_list_csv(hosts_str) if host != ""] + + for host in sorted(hosts): + try: + start, end = host.split("-", 1) + if "." not in end: + ip_1, ip_2, ip_3, _ = start.split(".", 3) + end = ".".join([ip_1, ip_2, ip_3, end]) + summarize_address_range(ip_address(start), ip_address(end)) + except ValueError: + pass + else: + normalized_hosts.append(host) + continue + + try: + normalized_hosts.append(str(ip_address(host))) + except ValueError: + pass + else: + continue + + try: + normalized_hosts.append(str(ip_network(host))) + except ValueError: + return None + + return normalized_hosts + + +def normalize_input(user_input: dict[str, Any]) -> dict[str, str]: + """Validate hosts and exclude are valid.""" + errors = {} + normalized_hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) + if not normalized_hosts: + errors[CONF_HOSTS] = "invalid_hosts" + else: + user_input[CONF_HOSTS] = ",".join(normalized_hosts) + + normalized_exclude = _normalize_ips_and_network(user_input[CONF_EXCLUDE]) + if normalized_exclude is None: + errors[CONF_EXCLUDE] = "invalid_hosts" + else: + user_input[CONF_EXCLUDE] = ",".join(normalized_exclude) + + return errors + + +async def _async_build_schema_with_user_input( + hass: HomeAssistant, user_input: dict[str, Any], include_options: bool +) -> vol.Schema: + hosts = user_input.get(CONF_HOSTS, await async_get_network(hass)) + exclude = user_input.get( + CONF_EXCLUDE, await network.async_get_source_ip(hass, MDNS_TARGET_IP) + ) + schema = { + vol.Required(CONF_HOSTS, default=hosts): str, + vol.Required( + CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) + ): int, + vol.Optional(CONF_EXCLUDE, default=exclude): str, + vol.Optional( + CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) + ): str, + } + if include_options: + schema.update( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), + ): vol.All(vol.Coerce(int), vol.Range(min=10, max=MAX_SCAN_INTERVAL)), + vol.Optional( + CONF_CONSIDER_HOME, + default=user_input.get(CONF_CONSIDER_HOME) + or DEFAULT_CONSIDER_HOME.total_seconds(), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_CONSIDER_HOME)), + } + ) + return vol.Schema(schema) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for homekit.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.options = dict(config_entry.options) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + errors = {} + if user_input is not None: + errors = normalize_input(user_input) + self.options.update(user_input) + + if not errors: + return self.async_create_entry( + title=f"Nmap Tracker {self.options[CONF_HOSTS]}", data=self.options + ) + + return self.async_show_form( + step_id="init", + data_schema=await _async_build_schema_with_user_input( + self.hass, self.options, True + ), + errors=errors, + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nmap Tracker.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self.options: dict[str, Any] = {} + + 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: + if not self._async_is_unique_host_list(user_input): + return self.async_abort(reason="already_configured") + + errors = normalize_input(user_input) + self.options.update(user_input) + + if not errors: + return self.async_create_entry( + title=f"Nmap Tracker {user_input[CONF_HOSTS]}", + data={}, + options=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=await _async_build_schema_with_user_input( + self.hass, self.options, False + ), + errors=errors, + ) + + def _async_is_unique_host_list(self, user_input: dict[str, Any]) -> bool: + hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) + for entry in self._async_current_entries(): + if _normalize_ips_and_network(entry.options[CONF_HOSTS]) == hosts: + return False + return True + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import from yaml.""" + if not self._async_is_unique_host_list(user_input): + return self.async_abort(reason="already_configured") + + normalize_input(user_input) + + return self.async_create_entry( + title=f"Nmap Tracker {user_input[CONF_HOSTS]}", data={}, options=user_input + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/nmap_tracker/const.py b/homeassistant/components/nmap_tracker/const.py index 88118a81811..e25368b22cc 100644 --- a/homeassistant/components/nmap_tracker/const.py +++ b/homeassistant/components/nmap_tracker/const.py @@ -1,16 +1,15 @@ """The Nmap Tracker integration.""" +from typing import Final -DOMAIN = "nmap_tracker" +DOMAIN: Final = "nmap_tracker" -PLATFORMS = ["device_tracker"] +PLATFORMS: Final = ["device_tracker"] -NMAP_TRACKED_DEVICES = "nmap_tracked_devices" +NMAP_TRACKED_DEVICES: Final = "nmap_tracked_devices" # Interval in minutes to exclude devices from a scan while they are home -CONF_HOME_INTERVAL = "home_interval" -CONF_OPTIONS = "scan_options" -DEFAULT_OPTIONS = "-F --host-timeout 5s" +CONF_HOME_INTERVAL: Final = "home_interval" +CONF_OPTIONS: Final = "scan_options" +DEFAULT_OPTIONS: Final = "-F -T4 --min-rate 10 --host-timeout 5s" -TRACKER_SCAN_INTERVAL = 120 - -DEFAULT_TRACK_NEW_DEVICES = True +TRACKER_SCAN_INTERVAL: Final = 120 diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 69c65873e51..e475afd24c8 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -1,133 +1,219 @@ """Support for scanning a network with nmap.""" -from collections import namedtuple -from datetime import timedelta -import logging +from __future__ import annotations + +import logging +from typing import Any, Callable -from getmac import get_mac_address -from nmap import PortScanner, PortScannerError import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, - PLATFORM_SCHEMA, - DeviceScanner, + DOMAIN as DEVICE_TRACKER_DOMAIN, + PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, + SOURCE_TYPE_ROUTER, ) +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import ConfigType + +from . import NmapDevice, NmapDeviceScanner, short_hostname, signal_device_update +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) _LOGGER = logging.getLogger(__name__) -# Interval in minutes to exclude devices from a scan while they are home -CONF_HOME_INTERVAL = "home_interval" -CONF_OPTIONS = "scan_options" -DEFAULT_OPTIONS = "-F --host-timeout 5s" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, + vol.Required( + CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds() + ): cv.time_period, vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string, } ) -def get_scanner(hass, config): +async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None: """Validate the configuration and return a Nmap scanner.""" - return NmapDeviceScanner(config[DOMAIN]) + validated_config = config[DEVICE_TRACKER_DOMAIN] + if CONF_SCAN_INTERVAL in validated_config: + scan_interval = validated_config[CONF_SCAN_INTERVAL].total_seconds() + else: + scan_interval = TRACKER_SCAN_INTERVAL -Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) + if CONF_CONSIDER_HOME in validated_config: + consider_home = validated_config[CONF_CONSIDER_HOME].total_seconds() + else: + consider_home = DEFAULT_CONSIDER_HOME.total_seconds() + import_config = { + CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), + CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], + CONF_CONSIDER_HOME: consider_home, + CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), + CONF_OPTIONS: validated_config[CONF_OPTIONS], + CONF_SCAN_INTERVAL: scan_interval, + } -class NmapDeviceScanner(DeviceScanner): - """This class scans for devices using nmap.""" - - exclude = [] - - def __init__(self, config): - """Initialize the scanner.""" - self.last_results = [] - - self.hosts = config[CONF_HOSTS] - self.exclude = config[CONF_EXCLUDE] - minutes = config[CONF_HOME_INTERVAL] - self._options = config[CONF_OPTIONS] - self.home_interval = timedelta(minutes=minutes) - - _LOGGER.debug("Scanner initialized") - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - _LOGGER.debug("Nmap last results %s", self.last_results) - - return [device.mac for device in self.last_results] - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - filter_named = [ - result.name for result in self.last_results if result.mac == device - ] - - if filter_named: - return filter_named[0] - return None - - def get_extra_attributes(self, device): - """Return the IP of the given device.""" - filter_ip = next( - (result.ip for result in self.last_results if result.mac == device), None + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=import_config, ) - return {"ip": filter_ip} + ) - def _update_info(self): - """Scan the network for devices. + _LOGGER.warning( + "Your Nmap Tracker configuration has been imported into the UI, " + "please remove it from configuration.yaml. " + ) - Returns boolean if scanning successful. - """ - _LOGGER.debug("Scanning") - scanner = PortScanner() +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Set up device tracker for Nmap Tracker component.""" + nmap_tracker = hass.data[DOMAIN][entry.entry_id] - options = self._options + @callback + def device_new(mac_address): + """Signal a new device.""" + async_add_entities([NmapTrackerEntity(nmap_tracker, mac_address, True)]) - if self.home_interval: - boundary = dt_util.now() - self.home_interval - last_results = [ - device for device in self.last_results if device.last_update > boundary - ] - if last_results: - exclude_hosts = self.exclude + [device.ip for device in last_results] - else: - exclude_hosts = self.exclude - else: - last_results = [] - exclude_hosts = self.exclude - if exclude_hosts: - options += f" --exclude {','.join(exclude_hosts)}" + @callback + def device_missing(mac_address): + """Signal a missing device.""" + async_add_entities([NmapTrackerEntity(nmap_tracker, mac_address, False)]) - try: - result = scanner.scan(hosts=" ".join(self.hosts), arguments=options) - except PortScannerError: - return False + entry.async_on_unload( + async_dispatcher_connect(hass, nmap_tracker.signal_device_new, device_new) + ) + entry.async_on_unload( + async_dispatcher_connect( + hass, nmap_tracker.signal_device_missing, device_missing + ) + ) - now = dt_util.now() - for ipv4, info in result["scan"].items(): - if info["status"]["state"] != "up": - continue - name = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 - # Mac address only returned if nmap ran as root - mac = info["addresses"].get("mac") or get_mac_address(ip=ipv4) - if mac is None: - _LOGGER.info("No MAC address found for %s", ipv4) - continue - last_results.append(Device(mac.upper(), name, ipv4, now)) - self.last_results = last_results +class NmapTrackerEntity(ScannerEntity): + """An Nmap Tracker entity.""" - _LOGGER.debug("nmap scan successful") - return True + def __init__( + self, nmap_tracker: NmapDeviceScanner, mac_address: str, active: bool + ) -> None: + """Initialize an nmap tracker entity.""" + self._mac_address = mac_address + self._nmap_tracker = nmap_tracker + self._tracked = self._nmap_tracker.devices.tracked + self._active = active + + @property + def _device(self) -> NmapDevice: + """Get latest device state.""" + return self._tracked[self._mac_address] + + @property + def is_connected(self) -> bool: + """Return device status.""" + return self._active + + @property + def name(self) -> str: + """Return device name.""" + return self._device.name + + @property + def unique_id(self) -> str: + """Return device unique id.""" + return self._mac_address + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self._device.ipv4 + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._mac_address + + @property + def hostname(self) -> str | None: + """Return hostname of the device.""" + if not self._device.hostname: + return None + return short_hostname(self._device.hostname) + + @property + def source_type(self) -> str: + """Return tracker source type.""" + return SOURCE_TYPE_ROUTER + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac_address)}, + "default_manufacturer": self._device.manufacturer, + "default_name": self.name, + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:lan-connect" if self._active else "mdi:lan-disconnect" + + @callback + def async_process_update(self, online: bool) -> None: + """Update device.""" + self._active = online + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the attributes.""" + return { + "last_time_reachable": self._device.last_update.isoformat( + timespec="seconds" + ), + "reason": self._device.reason, + } + + @callback + def async_on_demand_update(self, online: bool) -> None: + """Update state.""" + self.async_process_update(online) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register state update callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + signal_device_update(self._mac_address), + self.async_on_demand_update, + ) + ) diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 9f81c0facaf..bbd15834ef2 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -2,7 +2,13 @@ "domain": "nmap_tracker", "name": "Nmap Tracker", "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", - "requirements": ["python-nmap==0.6.1", "getmac==0.8.2"], - "codeowners": [], - "iot_class": "local_polling" + "dependencies": ["network"], + "requirements": [ + "netmap==0.7.0.2", + "getmac==0.8.2", + "mac-vendor-lookup==0.1.11" + ], + "codeowners": ["@bdraco"], + "iot_class": "local_polling", + "config_flow": true } diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index ecb470a6f0d..ed5a8cb0b05 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -7,9 +7,9 @@ "data": { "hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]", "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", - "track_new_devices": "Track new devices", "interval_seconds": "Scan interval" } } diff --git a/homeassistant/components/nmap_tracker/translations/cs.json b/homeassistant/components/nmap_tracker/translations/cs.json index 1a0d0ae0b53..ac5f913d8e6 100644 --- a/homeassistant/components/nmap_tracker/translations/cs.json +++ b/homeassistant/components/nmap_tracker/translations/cs.json @@ -3,5 +3,15 @@ "abort": { "already_configured": "Um\u00edst\u011bn\u00ed je ji\u017e nastaveno" } + }, + "options": { + "step": { + "init": { + "data": { + "interval_seconds": "Interval skenov\u00e1n\u00ed", + "track_new_devices": "Sledovat nov\u00e1 za\u0159\u00edzen\u00ed" + } + } + } } } \ 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 6b83532a0e2..feeea1ff8be 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -25,12 +25,12 @@ "step": { "init": { "data": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "Network addresses (comma seperated) to exclude from scanning", "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", - "track_new_devices": "Track new devices" + "scan_options": "Raw configurable scan options for Nmap" }, "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/no.json b/homeassistant/components/nmap_tracker/translations/no.json index 487d15c910f..03a241bc3a2 100644 --- a/homeassistant/components/nmap_tracker/translations/no.json +++ b/homeassistant/components/nmap_tracker/translations/no.json @@ -28,7 +28,9 @@ "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", - "scan_options": "R\u00e5 konfigurerbare skannealternativer for Nmap" + "interval_seconds": "Skanneintervall", + "scan_options": "R\u00e5 konfigurerbare skannealternativer for Nmap", + "track_new_devices": "Spor nye enheter" }, "description": "Konfigurer verter som skal skannes av Nmap. Nettverksadresse og ekskluderer kan v\u00e6re IP-adresser (192.168.1.1), IP-nettverk (192.168.0.0/24) eller IP-omr\u00e5der (192.168.1.0-32)." } diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 26f7dbd2c8a..72e51837bb8 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -120,7 +120,7 @@ class NMBSLiveBoard(SensorEntity): return DEFAULT_ICON @property - def state(self): + def native_value(self): """Return sensor state.""" return self._state @@ -166,7 +166,7 @@ class NMBSLiveBoard(SensorEntity): class NMBSSensor(SensorEntity): """Get the the total travel time for a given connection.""" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__( self, api_client, name, show_on_map, station_from, station_to, excl_vias @@ -238,7 +238,7 @@ class NMBSSensor(SensorEntity): return attrs @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index e637e953173..5dbee551bb7 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -107,7 +107,7 @@ class NOAATidesAndCurrentsSensor(SensorEntity): return attr @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.data is None: return None diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index 480121846e9..8e829355ea0 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -92,31 +92,31 @@ class AirSensor(AirQualityEntity): """Return the name of the sensor.""" return self._name - @property + @property # type: ignore @round_state def air_quality_index(self): """Return the Air Quality Index (AQI).""" return self._api.data.get("aqi") - @property + @property # type: ignore @round_state def nitrogen_dioxide(self): """Return the NO2 (nitrogen dioxide) level.""" return self._api.data.get("no2_concentration") - @property + @property # type: ignore @round_state def ozone(self): """Return the O3 (ozone) level.""" return self._api.data.get("o3_concentration") - @property + @property # type: ignore @round_state def particulate_matter_2_5(self): """Return the particulate matter 2.5 level.""" return self._api.data.get("pm25_concentration") - @property + @property # type: ignore @round_state def particulate_matter_10(self): """Return the particulate matter 10 level.""" diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 41953ddfc75..00047f0a32b 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -183,7 +183,6 @@ class BaseNotificationService: if hasattr(self, "targets"): stale_targets = set(self.registered_targets) - # pylint: disable=no-member for name, target in self.targets.items(): # type: ignore target_name = slugify(f"{self._target_service_name_prefix}_{name}") if target_name in stale_targets: diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index aab10916514..c06a08560e9 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -17,6 +17,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -137,14 +138,11 @@ class NotionEntity(CoordinatorEntity): sensor_id: str, bridge_id: str, system_id: str, - name: str, - device_class: str, + description: EntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attr_device_class = device_class - bridge = self.coordinator.data["bridges"].get(bridge_id, {}) sensor = self.coordinator.data["sensors"][sensor_id] self._attr_device_info = { @@ -157,7 +155,7 @@ class NotionEntity(CoordinatorEntity): } self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._attr_name = f'{sensor["name"]}: {name}' + self._attr_name = f'{sensor["name"]}: {description.name}' self._attr_unique_id = ( f'{sensor_id}_{coordinator.data["tasks"][task_id]["task_type"]}' ) @@ -165,6 +163,7 @@ class NotionEntity(CoordinatorEntity): self._sensor_id = sensor_id self._system_id = system_id self._task_id = task_id + self.entity_description = description @property def available(self) -> bool: diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index d3b1d8e3ef2..15c5877ae77 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -1,11 +1,16 @@ """Support for Notion binary sensors.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY, DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_WINDOW, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -28,18 +33,58 @@ from .const import ( SENSOR_WINDOW_HINGED_VERTICAL, ) -BINARY_SENSOR_TYPES = { - SENSOR_BATTERY: ("Low Battery", "battery"), - SENSOR_DOOR: ("Door", DEVICE_CLASS_DOOR), - SENSOR_GARAGE_DOOR: ("Garage Door", "garage_door"), - SENSOR_LEAK: ("Leak Detector", DEVICE_CLASS_MOISTURE), - SENSOR_MISSING: ("Missing", DEVICE_CLASS_CONNECTIVITY), - SENSOR_SAFE: ("Safe", DEVICE_CLASS_DOOR), - SENSOR_SLIDING: ("Sliding Door/Window", DEVICE_CLASS_DOOR), - SENSOR_SMOKE_CO: ("Smoke/Carbon Monoxide Detector", DEVICE_CLASS_SMOKE), - SENSOR_WINDOW_HINGED_HORIZONTAL: ("Hinged Window", DEVICE_CLASS_WINDOW), - SENSOR_WINDOW_HINGED_VERTICAL: ("Hinged Window", DEVICE_CLASS_WINDOW), -} +BINARY_SENSOR_DESCRIPTIONS = ( + BinarySensorEntityDescription( + key=SENSOR_BATTERY, + name="Low Battery", + device_class=DEVICE_CLASS_BATTERY, + ), + BinarySensorEntityDescription( + key=SENSOR_DOOR, + name="Door", + device_class=DEVICE_CLASS_DOOR, + ), + BinarySensorEntityDescription( + key=SENSOR_GARAGE_DOOR, + name="Garage Door", + device_class=DEVICE_CLASS_GARAGE_DOOR, + ), + BinarySensorEntityDescription( + key=SENSOR_LEAK, + name="Leak Detector", + device_class=DEVICE_CLASS_MOISTURE, + ), + BinarySensorEntityDescription( + key=SENSOR_MISSING, + name="Missing", + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + BinarySensorEntityDescription( + key=SENSOR_SAFE, + name="Safe", + device_class=DEVICE_CLASS_DOOR, + ), + BinarySensorEntityDescription( + key=SENSOR_SLIDING, + name="Sliding Door/Window", + device_class=DEVICE_CLASS_DOOR, + ), + BinarySensorEntityDescription( + key=SENSOR_SMOKE_CO, + name="Smoke/Carbon Monoxide Detector", + device_class=DEVICE_CLASS_SMOKE, + ), + BinarySensorEntityDescription( + key=SENSOR_WINDOW_HINGED_HORIZONTAL, + name="Hinged Window", + device_class=DEVICE_CLASS_WINDOW, + ), + BinarySensorEntityDescription( + key=SENSOR_WINDOW_HINGED_VERTICAL, + name="Hinged Window", + device_class=DEVICE_CLASS_WINDOW, + ), +) async def async_setup_entry( @@ -48,27 +93,22 @@ async def async_setup_entry( """Set up Notion sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] - sensor_list = [] - for task_id, task in coordinator.data["tasks"].items(): - if task["task_type"] not in BINARY_SENSOR_TYPES: - continue - - name, device_class = BINARY_SENSOR_TYPES[task["task_type"]] - sensor = coordinator.data["sensors"][task["sensor_id"]] - - sensor_list.append( + async_add_entities( + [ NotionBinarySensor( coordinator, task_id, sensor["id"], sensor["bridge"]["id"], sensor["system_id"], - name, - device_class, + description, ) - ) - - async_add_entities(sensor_list) + for task_id, task in coordinator.data["tasks"].items() + for description in BINARY_SENSOR_DESCRIPTIONS + if description.key == task["task_type"] + and (sensor := coordinator.data["sensors"][task["sensor_id"]]) + ] + ) class NotionBinarySensor(NotionEntity, BinarySensorEntity): diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 48b9a25f783..803cfce3360 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,17 +1,21 @@ """Support for Notion sensors.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import NotionEntity from .const import DATA_COORDINATOR, DOMAIN, LOGGER, SENSOR_TEMPERATURE -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ("Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) -} +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), +) async def async_setup_entry( @@ -20,58 +24,34 @@ async def async_setup_entry( """Set up Notion sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] - sensor_list = [] - for task_id, task in coordinator.data["tasks"].items(): - if task["task_type"] not in SENSOR_TYPES: - continue - - name, device_class, unit = SENSOR_TYPES[task["task_type"]] - sensor = coordinator.data["sensors"][task["sensor_id"]] - - sensor_list.append( + async_add_entities( + [ NotionSensor( coordinator, task_id, sensor["id"], sensor["bridge"]["id"], sensor["system_id"], - name, - device_class, - unit, + description, ) - ) - - async_add_entities(sensor_list) + for task_id, task in coordinator.data["tasks"].items() + for description in SENSOR_DESCRIPTIONS + if description.key == task["task_type"] + and (sensor := coordinator.data["sensors"][task["sensor_id"]]) + ] + ) class NotionSensor(NotionEntity, SensorEntity): """Define a Notion sensor.""" - def __init__( - self, - coordinator: DataUpdateCoordinator, - task_id: str, - sensor_id: str, - bridge_id: str, - system_id: str, - name: str, - device_class: str, - unit: str, - ) -> None: - """Initialize the entity.""" - super().__init__( - coordinator, task_id, sensor_id, bridge_id, system_id, name, device_class - ) - - self._attr_unit_of_measurement = unit - @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" task = self.coordinator.data["tasks"][self._task_id] if task["task_type"] == SENSOR_TEMPERATURE: - self._attr_state = round(float(task["status"]["value"]), 1) + self._attr_native_value = round(float(task["status"]["value"]), 1) else: LOGGER.error( "Unknown task type: %s: %s", diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 52536e69027..139728a3405 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -99,7 +99,7 @@ class StationPriceSensor(CoordinatorEntity, SensorEntity): return f"{station_name} {self._fuel_type}" @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" if self.coordinator.data is None: return None @@ -117,7 +117,7 @@ class StationPriceSensor(CoordinatorEntity, SensorEntity): } @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the units of measurement.""" return f"{CURRENCY_CENT}/{VOLUME_LITERS}" diff --git a/homeassistant/components/nuheat/translations/hu.json b/homeassistant/components/nuheat/translations/hu.json index e6e7174e325..873b03cebff 100644 --- a/homeassistant/components/nuheat/translations/hu.json +++ b/homeassistant/components/nuheat/translations/hu.json @@ -15,7 +15,9 @@ "password": "Jelsz\u00f3", "serial_number": "A termoszt\u00e1t sorozatsz\u00e1ma.", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "A termoszt\u00e1t numerikus sorozatsz\u00e1m\u00e1t vagy azonos\u00edt\u00f3j\u00e1t meg kell szereznie, ha bejelentkezik a https://MyNuHeat.com oldalra, \u00e9s kiv\u00e1lasztja a termoszt\u00e1tot.", + "title": "Csatlakozzon a NuHeat-hez" } } } diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 19372de5258..fcf719c979e 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -78,12 +78,12 @@ class NumatoGpioAdc(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 88ba5cf8b41..ac727288b07 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -9,7 +9,7 @@ from typing import Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -49,12 +49,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_VALUE, {vol.Required(ATTR_VALUE): vol.Coerce(float)}, - "async_set_value", + async_set_value, ) return True +async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + value = service_call.data["value"] + if value < entity.min_value or value > entity.max_value: + raise ValueError( + f"Value {value} for {entity.name} is outside valid range {entity.min_value} - {entity.max_value}" + ) + await entity.async_set_value(value) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent = hass.data[DOMAIN] diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 77b36b49f20..77ca633d947 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -1,8 +1,6 @@ """Provides device actions for Number.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( @@ -15,6 +13,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from . import DOMAIN, const @@ -29,10 +28,12 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Number.""" registry = await entity_registry.async_get_registry(hass) - actions: list[dict[str, Any]] = [] + actions: list[dict[str, str]] = [] # Get all the integrations entities for this device for entry in entity_registry.async_entries_for_device(registry, device_id): @@ -67,7 +68,9 @@ async def async_call_action_from_config( ) -async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List action capabilities.""" fields = {vol.Required(const.ATTR_VALUE): vol.Coerce(float)} diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 0b5ad8bbc1f..70c097bd6f1 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -25,19 +25,12 @@ from .const import ( DOMAIN, KEY_STATUS, KEY_STATUS_DISPLAY, - SENSOR_NAME, SENSOR_TYPES, ) _LOGGER = logging.getLogger(__name__) -SENSOR_DICT = { - sensor_id: sensor_spec[SENSOR_NAME] - for sensor_id, sensor_spec in SENSOR_TYPES.items() -} - - def _base_schema(discovery_info): """Generate base schema.""" base_schema = {} @@ -59,15 +52,15 @@ def _resource_schema_base(available_resources, selected_resources): """Resource selection schema.""" known_available_resources = { - sensor_id: sensor[SENSOR_NAME] - for sensor_id, sensor in SENSOR_TYPES.items() + sensor_id: sensor_desc.name + for sensor_id, sensor_desc in SENSOR_TYPES.items() if sensor_id in available_resources } if KEY_STATUS in known_available_resources: known_available_resources[KEY_STATUS_DISPLAY] = SENSOR_TYPES[ KEY_STATUS_DISPLAY - ][SENSOR_NAME] + ].name return { vol.Required(CONF_RESOURCES, default=selected_resources): cv.multi_select( diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 1f5fecdd219..a180c2224f7 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -1,10 +1,17 @@ """The nut component.""" + +from __future__ import annotations + +from typing import Final + from homeassistant.components.sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, ) from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, @@ -40,246 +47,412 @@ PYNUT_MODEL = "model" PYNUT_FIRMWARE = "firmware" PYNUT_NAME = "name" -SENSOR_TYPES = { - "ups.status.display": ["Status", "", "mdi:information-outline", None], - "ups.status": ["Status Data", "", "mdi:information-outline", None], - "ups.alarm": ["Alarms", "", "mdi:alarm", None], - "ups.temperature": [ - "UPS Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "ups.load": ["Load", PERCENTAGE, "mdi:gauge", None], - "ups.load.high": ["Overload Setting", PERCENTAGE, "mdi:gauge", None], - "ups.id": ["System identifier", "", "mdi:information-outline", None], - "ups.delay.start": ["Load Restart Delay", TIME_SECONDS, "mdi:timer-outline", None], - "ups.delay.reboot": ["UPS Reboot Delay", TIME_SECONDS, "mdi:timer-outline", None], - "ups.delay.shutdown": [ - "UPS Shutdown Delay", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "ups.timer.start": ["Load Start Timer", TIME_SECONDS, "mdi:timer-outline", None], - "ups.timer.reboot": ["Load Reboot Timer", TIME_SECONDS, "mdi:timer-outline", None], - "ups.timer.shutdown": [ - "Load Shutdown Timer", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "ups.test.interval": [ - "Self-Test Interval", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "ups.test.result": ["Self-Test Result", "", "mdi:information-outline", None], - "ups.test.date": ["Self-Test Date", "", "mdi:calendar", None], - "ups.display.language": ["Language", "", "mdi:information-outline", None], - "ups.contacts": ["External Contacts", "", "mdi:information-outline", None], - "ups.efficiency": ["Efficiency", PERCENTAGE, "mdi:gauge", None], - "ups.power": ["Current Apparent Power", POWER_VOLT_AMPERE, "mdi:flash", None], - "ups.power.nominal": ["Nominal Power", POWER_VOLT_AMPERE, "mdi:flash", None], - "ups.realpower": [ - "Current Real Power", - POWER_WATT, - None, - DEVICE_CLASS_POWER, - ], - "ups.realpower.nominal": [ - "Nominal Real Power", - POWER_WATT, - None, - DEVICE_CLASS_POWER, - ], - "ups.beeper.status": ["Beeper Status", "", "mdi:information-outline", None], - "ups.type": ["UPS Type", "", "mdi:information-outline", None], - "ups.watchdog.status": ["Watchdog Status", "", "mdi:information-outline", None], - "ups.start.auto": ["Start on AC", "", "mdi:information-outline", None], - "ups.start.battery": ["Start on Battery", "", "mdi:information-outline", None], - "ups.start.reboot": ["Reboot on Battery", "", "mdi:information-outline", None], - "ups.shutdown": ["Shutdown Ability", "", "mdi:information-outline", None], - "battery.charge": [ - "Battery Charge", - PERCENTAGE, - None, - DEVICE_CLASS_BATTERY, - ], - "battery.charge.low": ["Low Battery Setpoint", PERCENTAGE, "mdi:gauge", None], - "battery.charge.restart": [ - "Minimum Battery to Start", - PERCENTAGE, - "mdi:gauge", - None, - ], - "battery.charge.warning": [ - "Warning Battery Setpoint", - PERCENTAGE, - "mdi:gauge", - None, - ], - "battery.charger.status": ["Charging Status", "", "mdi:information-outline", None], - "battery.voltage": [ - "Battery Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "battery.voltage.nominal": [ - "Nominal Battery Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "battery.voltage.low": [ - "Low Battery Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "battery.voltage.high": [ - "High Battery Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "battery.capacity": ["Battery Capacity", "Ah", "mdi:flash", None], - "battery.current": [ - "Battery Current", - ELECTRIC_CURRENT_AMPERE, - "mdi:flash", - None, - ], - "battery.current.total": [ - "Total Battery Current", - ELECTRIC_CURRENT_AMPERE, - "mdi:flash", - None, - ], - "battery.temperature": [ - "Battery Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "battery.runtime": ["Battery Runtime", TIME_SECONDS, "mdi:timer-outline", None], - "battery.runtime.low": [ - "Low Battery Runtime", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "battery.runtime.restart": [ - "Minimum Battery Runtime to Start", - TIME_SECONDS, - "mdi:timer-outline", - None, - ], - "battery.alarm.threshold": [ - "Battery Alarm Threshold", - "", - "mdi:information-outline", - None, - ], - "battery.date": ["Battery Date", "", "mdi:calendar", None], - "battery.mfr.date": ["Battery Manuf. Date", "", "mdi:calendar", None], - "battery.packs": ["Number of Batteries", "", "mdi:information-outline", None], - "battery.packs.bad": [ - "Number of Bad Batteries", - "", - "mdi:information-outline", - None, - ], - "battery.type": ["Battery Chemistry", "", "mdi:information-outline", None], - "input.sensitivity": [ - "Input Power Sensitivity", - "", - "mdi:information-outline", - None, - ], - "input.transfer.low": [ - "Low Voltage Transfer", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "input.transfer.high": [ - "High Voltage Transfer", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "input.transfer.reason": [ - "Voltage Transfer Reason", - "", - "mdi:information-outline", - None, - ], - "input.voltage": [ - "Input Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "input.voltage.nominal": [ - "Nominal Input Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "input.frequency": ["Input Line Frequency", FREQUENCY_HERTZ, "mdi:flash", None], - "input.frequency.nominal": [ - "Nominal Input Line Frequency", - FREQUENCY_HERTZ, - "mdi:flash", - None, - ], - "input.frequency.status": [ - "Input Frequency Status", - "", - "mdi:information-outline", - None, - ], - "output.current": ["Output Current", ELECTRIC_CURRENT_AMPERE, "mdi:flash", None], - "output.current.nominal": [ - "Nominal Output Current", - ELECTRIC_CURRENT_AMPERE, - "mdi:flash", - None, - ], - "output.voltage": [ - "Output Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "output.voltage.nominal": [ - "Nominal Output Voltage", - ELECTRIC_POTENTIAL_VOLT, - None, - DEVICE_CLASS_VOLTAGE, - ], - "output.frequency": ["Output Frequency", FREQUENCY_HERTZ, "mdi:flash", None], - "output.frequency.nominal": [ - "Nominal Output Frequency", - FREQUENCY_HERTZ, - "mdi:flash", - None, - ], - "ambient.humidity": [ - "Ambient Humidity", - PERCENTAGE, - None, - DEVICE_CLASS_HUMIDITY, - ], - "ambient.temperature": [ - "Ambient Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], +SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { + "ups.status.display": SensorEntityDescription( + key="ups.status.display", + name="Status", + icon="mdi:information-outline", + ), + "ups.status": SensorEntityDescription( + key="ups.status", + name="Status Data", + icon="mdi:information-outline", + ), + "ups.alarm": SensorEntityDescription( + key="ups.alarm", + name="Alarms", + icon="mdi:alarm", + ), + "ups.temperature": SensorEntityDescription( + key="ups.temperature", + name="UPS Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.load": SensorEntityDescription( + key="ups.load", + name="Load", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.load.high": SensorEntityDescription( + key="ups.load.high", + name="Overload Setting", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + "ups.id": SensorEntityDescription( + key="ups.id", + name="System identifier", + icon="mdi:information-outline", + ), + "ups.delay.start": SensorEntityDescription( + key="ups.delay.start", + name="Load Restart Delay", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "ups.delay.reboot": SensorEntityDescription( + key="ups.delay.reboot", + name="UPS Reboot Delay", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "ups.delay.shutdown": SensorEntityDescription( + key="ups.delay.shutdown", + name="UPS Shutdown Delay", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "ups.timer.start": SensorEntityDescription( + key="ups.timer.start", + name="Load Start Timer", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "ups.timer.reboot": SensorEntityDescription( + key="ups.timer.reboot", + name="Load Reboot Timer", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "ups.timer.shutdown": SensorEntityDescription( + key="ups.timer.shutdown", + name="Load Shutdown Timer", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "ups.test.interval": SensorEntityDescription( + key="ups.test.interval", + name="Self-Test Interval", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "ups.test.result": SensorEntityDescription( + key="ups.test.result", + name="Self-Test Result", + icon="mdi:information-outline", + ), + "ups.test.date": SensorEntityDescription( + key="ups.test.date", + name="Self-Test Date", + icon="mdi:calendar", + ), + "ups.display.language": SensorEntityDescription( + key="ups.display.language", + name="Language", + icon="mdi:information-outline", + ), + "ups.contacts": SensorEntityDescription( + key="ups.contacts", + name="External Contacts", + icon="mdi:information-outline", + ), + "ups.efficiency": SensorEntityDescription( + key="ups.efficiency", + name="Efficiency", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.power": SensorEntityDescription( + key="ups.power", + name="Current Apparent Power", + native_unit_of_measurement=POWER_VOLT_AMPERE, + icon="mdi:flash", + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.power.nominal": SensorEntityDescription( + key="ups.power.nominal", + name="Nominal Power", + native_unit_of_measurement=POWER_VOLT_AMPERE, + icon="mdi:flash", + ), + "ups.realpower": SensorEntityDescription( + key="ups.realpower", + name="Current Real Power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ups.realpower.nominal": SensorEntityDescription( + key="ups.realpower.nominal", + name="Nominal Real Power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + "ups.beeper.status": SensorEntityDescription( + key="ups.beeper.status", + name="Beeper Status", + icon="mdi:information-outline", + ), + "ups.type": SensorEntityDescription( + key="ups.type", + name="UPS Type", + icon="mdi:information-outline", + ), + "ups.watchdog.status": SensorEntityDescription( + key="ups.watchdog.status", + name="Watchdog Status", + icon="mdi:information-outline", + ), + "ups.start.auto": SensorEntityDescription( + key="ups.start.auto", + name="Start on AC", + icon="mdi:information-outline", + ), + "ups.start.battery": SensorEntityDescription( + key="ups.start.battery", + name="Start on Battery", + icon="mdi:information-outline", + ), + "ups.start.reboot": SensorEntityDescription( + key="ups.start.reboot", + name="Reboot on Battery", + icon="mdi:information-outline", + ), + "ups.shutdown": SensorEntityDescription( + key="ups.shutdown", + name="Shutdown Ability", + icon="mdi:information-outline", + ), + "battery.charge": SensorEntityDescription( + key="battery.charge", + name="Battery Charge", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + "battery.charge.low": SensorEntityDescription( + key="battery.charge.low", + name="Low Battery Setpoint", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + "battery.charge.restart": SensorEntityDescription( + key="battery.charge.restart", + name="Minimum Battery to Start", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + "battery.charge.warning": SensorEntityDescription( + key="battery.charge.warning", + name="Warning Battery Setpoint", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + "battery.charger.status": SensorEntityDescription( + key="battery.charger.status", + name="Charging Status", + icon="mdi:information-outline", + ), + "battery.voltage": SensorEntityDescription( + key="battery.voltage", + name="Battery Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "battery.voltage.nominal": SensorEntityDescription( + key="battery.voltage.nominal", + name="Nominal Battery Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + "battery.voltage.low": SensorEntityDescription( + key="battery.voltage.low", + name="Low Battery Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + "battery.voltage.high": SensorEntityDescription( + key="battery.voltage.high", + name="High Battery Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + "battery.capacity": SensorEntityDescription( + key="battery.capacity", + name="Battery Capacity", + native_unit_of_measurement="Ah", + icon="mdi:flash", + ), + "battery.current": SensorEntityDescription( + key="battery.current", + name="Battery Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + state_class=STATE_CLASS_MEASUREMENT, + ), + "battery.current.total": SensorEntityDescription( + key="battery.current.total", + name="Total Battery Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + ), + "battery.temperature": SensorEntityDescription( + key="battery.temperature", + name="Battery Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "battery.runtime": SensorEntityDescription( + key="battery.runtime", + name="Battery Runtime", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "battery.runtime.low": SensorEntityDescription( + key="battery.runtime.low", + name="Low Battery Runtime", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "battery.runtime.restart": SensorEntityDescription( + key="battery.runtime.restart", + name="Minimum Battery Runtime to Start", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-outline", + ), + "battery.alarm.threshold": SensorEntityDescription( + key="battery.alarm.threshold", + name="Battery Alarm Threshold", + icon="mdi:information-outline", + ), + "battery.date": SensorEntityDescription( + key="battery.date", + name="Battery Date", + icon="mdi:calendar", + ), + "battery.mfr.date": SensorEntityDescription( + key="battery.mfr.date", + name="Battery Manuf. Date", + icon="mdi:calendar", + ), + "battery.packs": SensorEntityDescription( + key="battery.packs", + name="Number of Batteries", + icon="mdi:information-outline", + ), + "battery.packs.bad": SensorEntityDescription( + key="battery.packs.bad", + name="Number of Bad Batteries", + icon="mdi:information-outline", + ), + "battery.type": SensorEntityDescription( + key="battery.type", + name="Battery Chemistry", + icon="mdi:information-outline", + ), + "input.sensitivity": SensorEntityDescription( + key="input.sensitivity", + name="Input Power Sensitivity", + icon="mdi:information-outline", + ), + "input.transfer.low": SensorEntityDescription( + key="input.transfer.low", + name="Low Voltage Transfer", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + "input.transfer.high": SensorEntityDescription( + key="input.transfer.high", + name="High Voltage Transfer", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + "input.transfer.reason": SensorEntityDescription( + key="input.transfer.reason", + name="Voltage Transfer Reason", + icon="mdi:information-outline", + ), + "input.voltage": SensorEntityDescription( + key="input.voltage", + name="Input Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "input.voltage.nominal": SensorEntityDescription( + key="input.voltage.nominal", + name="Nominal Input Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + "input.frequency": SensorEntityDescription( + key="input.frequency", + name="Input Line Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:flash", + state_class=STATE_CLASS_MEASUREMENT, + ), + "input.frequency.nominal": SensorEntityDescription( + key="input.frequency.nominal", + name="Nominal Input Line Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:flash", + ), + "input.frequency.status": SensorEntityDescription( + key="input.frequency.status", + name="Input Frequency Status", + icon="mdi:information-outline", + ), + "output.current": SensorEntityDescription( + key="output.current", + name="Output Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + state_class=STATE_CLASS_MEASUREMENT, + ), + "output.current.nominal": SensorEntityDescription( + key="output.current.nominal", + name="Nominal Output Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + icon="mdi:flash", + ), + "output.voltage": SensorEntityDescription( + key="output.voltage", + name="Output Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "output.voltage.nominal": SensorEntityDescription( + key="output.voltage.nominal", + name="Nominal Output Voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + ), + "output.frequency": SensorEntityDescription( + key="output.frequency", + name="Output Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:flash", + state_class=STATE_CLASS_MEASUREMENT, + ), + "output.frequency.nominal": SensorEntityDescription( + key="output.frequency.nominal", + name="Nominal Output Frequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + icon="mdi:flash", + ), + "ambient.humidity": SensorEntityDescription( + key="ambient.humidity", + name="Ambient Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + "ambient.temperature": SensorEntityDescription( + key="ambient.temperature", + name="Ambient Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), } STATE_TYPES = { @@ -299,8 +472,3 @@ STATE_TYPES = { "FSD": "Forced Shutdown", "ALARM": "Alarm", } - -SENSOR_NAME = 0 -SENSOR_UNIT = 1 -SENSOR_ICON = 2 -SENSOR_DEVICE_CLASS = 3 diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 1eb67e45aa5..995032eb0fd 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1,9 +1,15 @@ """Provides a sensor to track various status aspects of a UPS.""" +from __future__ import annotations + import logging -from homeassistant.components.sensor import SensorEntity +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.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import ( COORDINATOR, @@ -16,11 +22,7 @@ from .const import ( PYNUT_MODEL, PYNUT_NAME, PYNUT_UNIQUE_ID, - SENSOR_DEVICE_CLASS, - SENSOR_ICON, - SENSOR_NAME, SENSOR_TYPES, - SENSOR_UNIT, STATE_TYPES, ) @@ -60,7 +62,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator, data, name.title(), - sensor_type, + SENSOR_TYPES[sensor_type], unique_id, manufacturer, model, @@ -82,18 +84,18 @@ class NUTSensor(CoordinatorEntity, SensorEntity): def __init__( self, - coordinator, - data, - name, - sensor_type, - unique_id, - manufacturer, - model, - firmware, - ): + coordinator: DataUpdateCoordinator, + data: PyNUTData, + name: str, + sensor_description: SensorEntityDescription, + unique_id: str, + manufacturer: str | None, + model: str | None, + firmware: str | None, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._type = sensor_type + self.entity_description = sensor_description self._manufacturer = manufacturer self._firmware = firmware self._model = model @@ -101,10 +103,9 @@ class NUTSensor(CoordinatorEntity, SensorEntity): self._data = data self._unique_id = unique_id - self._attr_device_class = SENSOR_TYPES[self._type][SENSOR_DEVICE_CLASS] - self._attr_icon = SENSOR_TYPES[self._type][SENSOR_ICON] - self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][SENSOR_NAME]}" - self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][SENSOR_UNIT] + self._attr_name = f"{name} {sensor_description.name}" + if unique_id is not None: + self._attr_unique_id = f"{unique_id}_{sensor_description.key}" @property def device_info(self): @@ -124,20 +125,13 @@ class NUTSensor(CoordinatorEntity, SensorEntity): return device_info @property - def unique_id(self): - """Sensor Unique id.""" - if not self._unique_id: - return None - return f"{self._unique_id}_{self._type}" - - @property - def state(self): + def native_value(self): """Return entity state from ups.""" if not self._data.status: return None - if self._type == KEY_STATUS_DISPLAY: + if self.entity_description.key == KEY_STATUS_DISPLAY: return _format_display_state(self._data.status) - return self._data.status.get(self._type) + return self._data.status.get(self.entity_description.key) @property def extra_state_attributes(self): diff --git a/homeassistant/components/nut/translations/hu.json b/homeassistant/components/nut/translations/hu.json index a7bad455dc3..bfc8e01c11a 100644 --- a/homeassistant/components/nut/translations/hu.json +++ b/homeassistant/components/nut/translations/hu.json @@ -8,13 +8,27 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "resources": { + "data": { + "resources": "Forr\u00e1sok" + }, + "title": "V\u00e1lassza ki a nyomon k\u00f6vetend\u0151 er\u0151forr\u00e1sokat" + }, + "ups": { + "data": { + "alias": "\u00c1ln\u00e9v", + "resources": "Forr\u00e1sok" + }, + "title": "V\u00e1lassza ki a fel\u00fcgyelni k\u00edv\u00e1nt UPS-t" + }, "user": { "data": { "host": "Hoszt", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon a NUT szerverhez" } } }, @@ -22,6 +36,15 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "init": { + "data": { + "resources": "Forr\u00e1sok", + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)" + }, + "description": "V\u00e1lassza az \u00c9rz\u00e9kel\u0151 er\u0151forr\u00e1sokat." + } } } } \ No newline at end of file diff --git a/homeassistant/components/nut/translations/zh-Hans.json b/homeassistant/components/nut/translations/zh-Hans.json index 91522c7f609..4afd1ff0031 100644 --- a/homeassistant/components/nut/translations/zh-Hans.json +++ b/homeassistant/components/nut/translations/zh-Hans.json @@ -1,15 +1,34 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, "step": { "resources": { "data": { "resources": "\u8d44\u6e90" - } + }, + "title": "\u9009\u62e9\u8981\u76d1\u89c6\u7684\u8d44\u6e90" + }, + "ups": { + "data": { + "alias": "\u522b\u540d", + "resources": "\u8d44\u6e90" + }, + "title": "\u9009\u62e9\u8981\u76d1\u63a7\u7684 UPS" }, "user": { "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", "username": "\u7528\u6237\u540d" - } + }, + "title": "\u8fde\u63a5\u5230 NUT \u670d\u52a1\u5668" } } }, @@ -17,6 +36,15 @@ "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "unknown": "\u4e0d\u5728\u9884\u671f\u5185\u7684\u9519\u8bef" + }, + "step": { + "init": { + "data": { + "resources": "\u8d44\u6e90", + "scan_interval": "\u626b\u63cf\u95f4\u9694\uff08\u79d2\uff09" + }, + "description": "\u9009\u62e9\u8981\u76d1\u89c6\u7684\u8d44\u6e90" + } } } } \ No newline at end of file diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 6e08ef408d3..32018bc40bb 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -113,7 +113,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Dew Point", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), NWSSensorEntityDescription( @@ -121,7 +121,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Temperature", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), NWSSensorEntityDescription( @@ -129,7 +129,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Chill", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), NWSSensorEntityDescription( @@ -137,7 +137,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Heat Index", icon=None, device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, unit_convert=TEMP_CELSIUS, ), NWSSensorEntityDescription( @@ -145,7 +145,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Relative Humidity", icon=None, device_class=DEVICE_CLASS_HUMIDITY, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, unit_convert=PERCENTAGE, ), NWSSensorEntityDescription( @@ -153,7 +153,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Speed", icon="mdi:weather-windy", device_class=None, - unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, unit_convert=SPEED_MILES_PER_HOUR, ), NWSSensorEntityDescription( @@ -161,7 +161,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Gust", icon="mdi:weather-windy", device_class=None, - unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, unit_convert=SPEED_MILES_PER_HOUR, ), NWSSensorEntityDescription( @@ -169,7 +169,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Wind Direction", icon="mdi:compass-rose", device_class=None, - unit_of_measurement=DEGREE, + native_unit_of_measurement=DEGREE, unit_convert=DEGREE, ), NWSSensorEntityDescription( @@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Barometric Pressure", icon=None, device_class=DEVICE_CLASS_PRESSURE, - unit_of_measurement=PRESSURE_PA, + native_unit_of_measurement=PRESSURE_PA, unit_convert=PRESSURE_INHG, ), NWSSensorEntityDescription( @@ -185,7 +185,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Sea Level Pressure", icon=None, device_class=DEVICE_CLASS_PRESSURE, - unit_of_measurement=PRESSURE_PA, + native_unit_of_measurement=PRESSURE_PA, unit_convert=PRESSURE_INHG, ), NWSSensorEntityDescription( @@ -193,7 +193,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( name="Visibility", icon="mdi:eye", device_class=None, - unit_of_measurement=LENGTH_METERS, + native_unit_of_measurement=LENGTH_METERS, unit_convert=LENGTH_MILES, ), ) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 409856831a2..85b60ffd475 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -73,16 +73,16 @@ class NWSSensor(CoordinatorEntity, SensorEntity): self._attr_name = f"{station} {description.name}" if not hass.config.units.is_metric: - self._attr_unit_of_measurement = description.unit_convert + self._attr_native_unit_of_measurement = description.unit_convert @property - def state(self): + def native_value(self): """Return the state.""" value = self._nws.observation.get(self.entity_description.key) if value is None: return None # Set alias to unit property -> prevent unnecessary hasattr calls - unit_of_measurement = self.unit_of_measurement + unit_of_measurement = self.native_unit_of_measurement if unit_of_measurement == SPEED_MILES_PER_HOUR: return round(convert_distance(value, LENGTH_KILOMETERS, LENGTH_MILES)) if unit_of_measurement == LENGTH_MILES: diff --git a/homeassistant/components/nws/translations/hu.json b/homeassistant/components/nws/translations/hu.json index 1d674cacc7e..ec9bf3f4988 100644 --- a/homeassistant/components/nws/translations/hu.json +++ b/homeassistant/components/nws/translations/hu.json @@ -12,8 +12,11 @@ "data": { "api_key": "API kulcs", "latitude": "Sz\u00e9less\u00e9g", - "longitude": "Hossz\u00fas\u00e1g" - } + "longitude": "Hossz\u00fas\u00e1g", + "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" } } } diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index 71f885ce491..ebb3a7e4e66 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -60,7 +61,7 @@ SPEED_LIMIT_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NZBGet integration.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 97bced9e9c2..5bfde7e7c2b 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta 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 ( CONF_NAME, @@ -22,22 +22,56 @@ from .coordinator import NZBGetDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "article_cache": ["ArticleCacheMB", "Article Cache", DATA_MEGABYTES], - "average_download_rate": [ - "AverageDownloadRate", - "Average Speed", - DATA_RATE_MEGABYTES_PER_SECOND, - ], - "download_paused": ["DownloadPaused", "Download Paused", None], - "download_rate": ["DownloadRate", "Speed", DATA_RATE_MEGABYTES_PER_SECOND], - "download_size": ["DownloadedSizeMB", "Size", DATA_MEGABYTES], - "free_disk_space": ["FreeDiskSpaceMB", "Disk Free", DATA_MEGABYTES], - "post_job_count": ["PostJobCount", "Post Processing Jobs", "Jobs"], - "post_paused": ["PostPaused", "Post Processing Paused", None], - "remaining_size": ["RemainingSizeMB", "Queue Size", DATA_MEGABYTES], - "uptime": ["UpTimeSec", "Uptime", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="ArticleCacheMB", + name="Article Cache", + native_unit_of_measurement=DATA_MEGABYTES, + ), + SensorEntityDescription( + key="AverageDownloadRate", + name="Average Speed", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + ), + SensorEntityDescription( + key="DownloadPaused", + name="Download Paused", + ), + SensorEntityDescription( + key="DownloadRate", + name="Speed", + native_unit_of_measurement=DATA_RATE_MEGABYTES_PER_SECOND, + ), + SensorEntityDescription( + key="DownloadedSizeMB", + name="Size", + native_unit_of_measurement=DATA_MEGABYTES, + ), + SensorEntityDescription( + key="FreeDiskSpaceMB", + name="Disk Free", + native_unit_of_measurement=DATA_MEGABYTES, + ), + SensorEntityDescription( + key="PostJobCount", + name="Post Processing Jobs", + native_unit_of_measurement="Jobs", + ), + SensorEntityDescription( + key="PostPaused", + name="Post Processing Paused", + ), + SensorEntityDescription( + key="RemainingSizeMB", + name="Queue Size", + native_unit_of_measurement=DATA_MEGABYTES, + ), + SensorEntityDescription( + key="UpTimeSec", + name="Uptime", + device_class=DEVICE_CLASS_TIMESTAMP, + ), +) async def async_setup_entry( @@ -49,21 +83,12 @@ async def async_setup_entry( coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ DATA_COORDINATOR ] - sensors = [] + entities = [ + NZBGetSensor(coordinator, entry.entry_id, entry.data[CONF_NAME], description) + for description in SENSOR_TYPES + ] - for sensor_config in SENSOR_TYPES.values(): - sensors.append( - NZBGetSensor( - coordinator, - entry.entry_id, - entry.data[CONF_NAME], - sensor_config[0], - sensor_config[1], - sensor_config[2], - ) - ) - - async_add_entities(sensors) + async_add_entities(entities) class NZBGetSensor(NZBGetEntity, SensorEntity): @@ -74,53 +99,33 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): coordinator: NZBGetDataUpdateCoordinator, entry_id: str, entry_name: str, - sensor_type: str, - sensor_name: str, - unit_of_measurement: str | None = None, + description: SensorEntityDescription, ) -> None: """Initialize a new NZBGet sensor.""" - self._sensor_type = sensor_type - self._unique_id = f"{entry_id}_{sensor_type}" - self._unit_of_measurement = unit_of_measurement + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" super().__init__( coordinator=coordinator, entry_id=entry_id, - name=f"{entry_name} {sensor_name}", + name=f"{entry_name} {description.name}", ) @property - def device_class(self): - """Return the device class.""" - if "UpTimeSec" in self._sensor_type: - return DEVICE_CLASS_TIMESTAMP - - return None - - @property - def unique_id(self) -> str: - """Return the unique ID of the sensor.""" - return self._unique_id - - @property - def unit_of_measurement(self) -> str: - """Return the unit that the state of sensor is expressed in.""" - return self._unit_of_measurement - - @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - value = self.coordinator.data["status"].get(self._sensor_type) + sensor_type = self.entity_description.key + value = self.coordinator.data["status"].get(sensor_type) if value is None: - _LOGGER.warning("Unable to locate value for %s", self._sensor_type) + _LOGGER.warning("Unable to locate value for %s", sensor_type) return None - if "DownloadRate" in self._sensor_type and value > 0: + if "DownloadRate" in sensor_type and value > 0: # Convert download rate from Bytes/s to MBytes/s return round(value / 2 ** 20, 2) - if "UpTimeSec" in self._sensor_type and value > 0: + if "UpTimeSec" in sensor_type and value > 0: uptime = utcnow() - timedelta(seconds=value) return uptime.replace(microsecond=0).isoformat() diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 71af8dacba2..4c9b583a36b 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -73,7 +73,7 @@ class OASATelematicsSensor(SensorEntity): return DEVICE_CLASS_TIMESTAMP @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 639b9eb332f..7ba28ee0741 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -85,7 +85,7 @@ class ObihaiServiceSensors(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 16f6efce004..5b2b0af494c 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -107,7 +107,7 @@ class OctoPrintSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" sensor_unit = self.unit_of_measurement if sensor_unit in (TEMP_CELSIUS, PERCENTAGE): @@ -118,7 +118,7 @@ class OctoPrintSensor(SensorEntity): return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index b53c35e17b5..0638b32d105 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -48,7 +48,7 @@ class OhmconnectSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._data.get("active") == "True": return "Active" diff --git a/homeassistant/components/ombi/const.py b/homeassistant/components/ombi/const.py index 784b46a99b7..3ed67389003 100644 --- a/homeassistant/components/ombi/const.py +++ b/homeassistant/components/ombi/const.py @@ -1,4 +1,8 @@ """Support for Ombi.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntityDescription + ATTR_SEASON = "season" CONF_URLBASE = "urlbase" @@ -13,11 +17,35 @@ SERVICE_MOVIE_REQUEST = "submit_movie_request" SERVICE_MUSIC_REQUEST = "submit_music_request" SERVICE_TV_REQUEST = "submit_tv_request" -SENSOR_TYPES = { - "movies": {"type": "Movie requests", "icon": "mdi:movie"}, - "tv": {"type": "TV show requests", "icon": "mdi:television-classic"}, - "music": {"type": "Music album requests", "icon": "mdi:album"}, - "pending": {"type": "Pending requests", "icon": "mdi:clock-alert-outline"}, - "approved": {"type": "Approved requests", "icon": "mdi:check"}, - "available": {"type": "Available requests", "icon": "mdi:download"}, -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="movies", + name="Movie requests", + icon="mdi:movie", + ), + SensorEntityDescription( + key="tv", + name="TV show requests", + icon="mdi:television-classic", + ), + SensorEntityDescription( + key="music", + name="Music album requests", + icon="mdi:album", + ), + SensorEntityDescription( + key="pending", + name="Pending requests", + icon="mdi:clock-alert-outline", + ), + SensorEntityDescription( + key="approved", + name="Approved requests", + icon="mdi:check", + ), + SensorEntityDescription( + key="available", + name="Available requests", + icon="mdi:download", + ), +) diff --git a/homeassistant/components/ombi/sensor.py b/homeassistant/components/ombi/sensor.py index c91cf429c94..e8d7da78cc8 100644 --- a/homeassistant/components/ombi/sensor.py +++ b/homeassistant/components/ombi/sensor.py @@ -4,7 +4,7 @@ import logging from pyombi import OmbiError -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from .const import DOMAIN, SENSOR_TYPES @@ -18,60 +18,39 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if discovery_info is None: return - sensors = [] - ombi = hass.data[DOMAIN]["instance"] - for sensor, sensor_val in SENSOR_TYPES.items(): - sensor_label = sensor - sensor_type = sensor_val["type"] - sensor_icon = sensor_val["icon"] - sensors.append(OmbiSensor(sensor_label, sensor_type, ombi, sensor_icon)) + entities = [OmbiSensor(ombi, description) for description in SENSOR_TYPES] - add_entities(sensors, True) + add_entities(entities, True) class OmbiSensor(SensorEntity): """Representation of an Ombi sensor.""" - def __init__(self, label, sensor_type, ombi, icon): + def __init__(self, ombi, description: SensorEntityDescription): """Initialize the sensor.""" - self._state = None - self._label = label - self._type = sensor_type + self.entity_description = description self._ombi = ombi - self._icon = icon - @property - def name(self): - """Return the name of the sensor.""" - return f"Ombi {self._type}" - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return self._icon - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"Ombi {description.name}" def update(self): """Update the sensor.""" try: - if self._label == "movies": - self._state = self._ombi.movie_requests - elif self._label == "tv": - self._state = self._ombi.tv_requests - elif self._label == "music": - self._state = self._ombi.music_requests - elif self._label == "pending": - self._state = self._ombi.total_requests["pending"] - elif self._label == "approved": - self._state = self._ombi.total_requests["approved"] - elif self._label == "available": - self._state = self._ombi.total_requests["available"] + sensor_type = self.entity_description.key + if sensor_type == "movies": + self._attr_native_value = self._ombi.movie_requests + elif sensor_type == "tv": + self._attr_native_value = self._ombi.tv_requests + elif sensor_type == "music": + self._attr_native_value = self._ombi.music_requests + elif sensor_type == "pending": + self._attr_native_value = self._ombi.total_requests["pending"] + elif sensor_type == "approved": + self._attr_native_value = self._ombi.total_requests["approved"] + elif sensor_type == "available": + self._attr_native_value = self._ombi.total_requests["available"] except OmbiError as err: _LOGGER.warning("Unable to update Ombi sensor: %s", err) - self._state = None + self._attr_native_value = None diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index a103b8d112c..798c5abd69e 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -6,7 +6,12 @@ import logging from omnilogic import OmniLogic, OmniLogicException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -14,13 +19,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import ( - ALL_ITEM_KINDS, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - DOMAIN, -) +from .const import ALL_ITEM_KINDS, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/omnilogic/const.py b/homeassistant/components/omnilogic/const.py index 41db7be5064..a00a7d7021d 100644 --- a/homeassistant/components/omnilogic/const.py +++ b/homeassistant/components/omnilogic/const.py @@ -6,9 +6,6 @@ DEFAULT_SCAN_INTERVAL = 6 DEFAULT_PH_OFFSET = 0 COORDINATOR = "coordinator" OMNI_API = "omni_api" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" PUMP_TYPES = { "FMT_VARIABLE_SPEED_PUMP": "VARIABLE", diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 1f8de082868..f0382c01342 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -86,7 +86,7 @@ class OmnilogicSensor(OmniLogicEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the right unit of measure.""" return self._unit @@ -95,7 +95,7 @@ class OmniLogicTemperatureSensor(OmnilogicSensor): """Define an OmniLogic Temperature (Air/Water) Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the temperature sensor.""" sensor_data = self.coordinator.data[self._item_id][self._state_key] @@ -123,7 +123,7 @@ class OmniLogicPumpSpeedSensor(OmnilogicSensor): """Define an OmniLogic Pump Speed Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the pump speed sensor.""" pump_type = PUMP_TYPES[ @@ -158,7 +158,7 @@ class OmniLogicSaltLevelSensor(OmnilogicSensor): """Define an OmniLogic Salt Level Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the salt level sensor.""" salt_return = self.coordinator.data[self._item_id][self._state_key] @@ -177,7 +177,7 @@ class OmniLogicChlorinatorSensor(OmnilogicSensor): """Define an OmniLogic Chlorinator Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the chlorinator sensor.""" state = self.coordinator.data[self._item_id][self._state_key] @@ -188,7 +188,7 @@ class OmniLogicPHSensor(OmnilogicSensor): """Define an OmniLogic pH Sensor.""" @property - def state(self): + def native_value(self): """Return the state for the pH sensor.""" ph_state = self.coordinator.data[self._item_id][self._state_key] @@ -232,7 +232,7 @@ class OmniLogicORPSensor(OmnilogicSensor): ) @property - def state(self): + def native_value(self): """Return the state for the ORP sensor.""" orp_state = int(self.coordinator.data[self._item_id][self._state_key]) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 7449524d9e5..693d685f77c 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -28,49 +28,49 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", name="Temperature", - unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=TEMP_CELSIUS, icon=None, device_class=DEVICE_CLASS_TEMPERATURE, ), SensorEntityDescription( key="orp", name="Oxydo Reduction Potential", - unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, icon="mdi:pool", device_class=None, ), SensorEntityDescription( key="ph", name="pH", - unit_of_measurement=None, + native_unit_of_measurement=None, icon="mdi:pool", device_class=None, ), SensorEntityDescription( key="tds", name="TDS", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, icon="mdi:pool", device_class=None, ), SensorEntityDescription( key="battery", name="Battery", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=None, device_class=DEVICE_CLASS_BATTERY, ), SensorEntityDescription( key="rssi", name="RSSI", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, icon=None, device_class=DEVICE_CLASS_SIGNAL_STRENGTH, ), SensorEntityDescription( key="salt", name="Salt", - unit_of_measurement="mg/L", + native_unit_of_measurement="mg/L", icon="mdi:pool", device_class=None, ), @@ -164,7 +164,7 @@ class OndiloICO(CoordinatorEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Last value of the sensor.""" return self._devdata()["value"] diff --git a/homeassistant/components/ondilo_ico/translations/en_GB.json b/homeassistant/components/ondilo_ico/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/ondilo_ico/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 9671a787c41..ff2ee55d0bd 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -1,86 +1,102 @@ """Support for 1-Wire binary sensors.""" from __future__ import annotations +from dataclasses import dataclass import os -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + CONF_TYPE, +) 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, SENSOR_TYPE_SENSED -from .model import DeviceComponentDescription -from .onewire_entities import OneWireBaseEntity, OneWireProxyEntity +from .const import CONF_TYPE_OWSERVER, DOMAIN, READ_MODE_BOOL +from .onewire_entities import OneWireEntityDescription, OneWireProxyEntity from .onewirehub import OneWireHub -DEVICE_BINARY_SENSORS: dict[str, list[DeviceComponentDescription]] = { - # Family : { path, sensor_type } - "12": [ - { - "path": "sensed.A", - "name": "Sensed A", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.B", - "name": "Sensed B", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - ], - "29": [ - { - "path": "sensed.0", - "name": "Sensed 0", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.1", - "name": "Sensed 1", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.2", - "name": "Sensed 2", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.3", - "name": "Sensed 3", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.4", - "name": "Sensed 4", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.5", - "name": "Sensed 5", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.6", - "name": "Sensed 6", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - { - "path": "sensed.7", - "name": "Sensed 7", - "type": SENSOR_TYPE_SENSED, - "default_disabled": True, - }, - ], + +@dataclass +class OneWireBinarySensorEntityDescription( + OneWireEntityDescription, BinarySensorEntityDescription +): + """Class describing OneWire binary sensor entities.""" + + +DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { + "12": ( + OneWireBinarySensorEntityDescription( + key="sensed.A", + entity_registry_enabled_default=False, + name="Sensed A", + read_mode=READ_MODE_BOOL, + ), + OneWireBinarySensorEntityDescription( + key="sensed.B", + entity_registry_enabled_default=False, + name="Sensed B", + read_mode=READ_MODE_BOOL, + ), + ), + "29": ( + OneWireBinarySensorEntityDescription( + key="sensed.0", + entity_registry_enabled_default=False, + name="Sensed 0", + 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, + ), + ), } @@ -98,12 +114,12 @@ async def async_setup_entry( async_add_entities(entities, True) -def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: +def get_entities(onewirehub: OneWireHub) -> list[BinarySensorEntity]: """Get a list of entities.""" if not onewirehub.devices: return [] - entities: list[OneWireBaseEntity] = [] + entities: list[BinarySensorEntity] = [] for device in onewirehub.devices: family = device["family"] @@ -113,22 +129,23 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: if family not in DEVICE_BINARY_SENSORS: continue device_info: DeviceInfo = { - "identifiers": {(DOMAIN, device_id)}, - "manufacturer": "Maxim Integrated", - "model": device_type, - "name": device_id, + ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, + ATTR_MANUFACTURER: "Maxim Integrated", + ATTR_MODEL: device_type, + ATTR_NAME: device_id, } - for entity_specs in DEVICE_BINARY_SENSORS[family]: - entity_path = os.path.join( - os.path.split(device["path"])[0], entity_specs["path"] + for description in DEVICE_BINARY_SENSORS[family]: + device_file = os.path.join( + os.path.split(device["path"])[0], description.key ) + name = f"{device_id} {description.name}" entities.append( OneWireProxyBinarySensor( + description=description, device_id=device_id, - device_name=device_id, + device_file=device_file, device_info=device_info, - entity_path=entity_path, - entity_specs=entity_specs, + name=name, owproxy=onewirehub.owproxy, ) ) @@ -139,6 +156,8 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: class OneWireProxyBinarySensor(OneWireProxyEntity, BinarySensorEntity): """Implementation of a 1-Wire binary sensor.""" + entity_description: OneWireBinarySensorEntityDescription + @property def is_on(self) -> bool: """Return true if sensor is on.""" diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 468aa6b9acf..20b76ff236b 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -164,16 +164,3 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=DATA_SCHEMA_MOUNTDIR, errors=errors, ) - - async def async_step_import(self, platform_config: dict[str, Any]) -> FlowResult: - """Handle import configuration from YAML.""" - # OWServer - if platform_config[CONF_TYPE] == CONF_TYPE_OWSERVER: - if CONF_PORT not in platform_config: - platform_config[CONF_PORT] = DEFAULT_OWSERVER_PORT - return await self.async_step_owserver(platform_config) - - # SysBus - if CONF_MOUNT_DIR not in platform_config: - platform_config[CONF_MOUNT_DIR] = DEFAULT_SYSBUS_MOUNT_DIR - return await self.async_step_mount_dir(platform_config) diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 9112bf5e8f6..4d758146aff 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -4,20 +4,6 @@ from __future__ import annotations from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import ( - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - LIGHT_LUX, - PERCENTAGE, - PRESSURE_MBAR, - TEMP_CELSIUS, -) CONF_MOUNT_DIR = "mount_dir" CONF_NAMES = "names" @@ -33,34 +19,9 @@ DOMAIN = "onewire" PRESSURE_CBAR = "cbar" -SENSOR_TYPE_COUNT = "count" -SENSOR_TYPE_CURRENT = "current" -SENSOR_TYPE_HUMIDITY = "humidity" -SENSOR_TYPE_ILLUMINANCE = "illuminance" -SENSOR_TYPE_MOISTURE = "moisture" -SENSOR_TYPE_PRESSURE = "pressure" -SENSOR_TYPE_SENSED = "sensed" -SENSOR_TYPE_TEMPERATURE = "temperature" -SENSOR_TYPE_VOLTAGE = "voltage" -SENSOR_TYPE_WETNESS = "wetness" -SWITCH_TYPE_LATCH = "latch" -SWITCH_TYPE_PIO = "pio" - -SENSOR_TYPES: dict[str, list[str | None]] = { - # SensorType: [ Unit, DeviceClass ] - SENSOR_TYPE_TEMPERATURE: [TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - SENSOR_TYPE_HUMIDITY: [PERCENTAGE, DEVICE_CLASS_HUMIDITY], - SENSOR_TYPE_PRESSURE: [PRESSURE_MBAR, DEVICE_CLASS_PRESSURE], - SENSOR_TYPE_ILLUMINANCE: [LIGHT_LUX, DEVICE_CLASS_ILLUMINANCE], - SENSOR_TYPE_WETNESS: [PERCENTAGE, DEVICE_CLASS_HUMIDITY], - SENSOR_TYPE_MOISTURE: [PRESSURE_CBAR, DEVICE_CLASS_PRESSURE], - SENSOR_TYPE_COUNT: ["count", None], - SENSOR_TYPE_VOLTAGE: [ELECTRIC_POTENTIAL_VOLT, DEVICE_CLASS_VOLTAGE], - SENSOR_TYPE_CURRENT: [ELECTRIC_CURRENT_AMPERE, DEVICE_CLASS_CURRENT], - SENSOR_TYPE_SENSED: [None, None], - SWITCH_TYPE_LATCH: [None, None], - SWITCH_TYPE_PIO: [None, None], -} +READ_MODE_BOOL = "bool" +READ_MODE_FLOAT = "float" +READ_MODE_INT = "int" PLATFORMS = [ BINARY_SENSOR_DOMAIN, diff --git a/homeassistant/components/onewire/model.py b/homeassistant/components/onewire/model.py index 8dc841f16ba..2aaef861a50 100644 --- a/homeassistant/components/onewire/model.py +++ b/homeassistant/components/onewire/model.py @@ -4,15 +4,6 @@ from __future__ import annotations from typing import TypedDict -class DeviceComponentDescription(TypedDict, total=False): - """Device component description class.""" - - path: str - name: str - type: str - default_disabled: bool - - class OWServerDeviceDescription(TypedDict): """OWServer device description class.""" diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index b60d06739e8..e00733ae387 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -1,22 +1,24 @@ """Support for 1-Wire entities.""" from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any from pyownet import protocol -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from homeassistant.helpers.typing import StateType -from .const import ( - SENSOR_TYPE_COUNT, - SENSOR_TYPE_SENSED, - SENSOR_TYPES, - SWITCH_TYPE_LATCH, - SWITCH_TYPE_PIO, -) -from .model import DeviceComponentDescription +from .const import READ_MODE_BOOL, READ_MODE_INT + + +@dataclass +class OneWireEntityDescription(EntityDescription): + """Class describing OneWire entities.""" + + read_mode: str | None = None + _LOGGER = logging.getLogger(__name__) @@ -24,57 +26,32 @@ _LOGGER = logging.getLogger(__name__) class OneWireBaseEntity(Entity): """Implementation of a 1-Wire entity.""" + entity_description: OneWireEntityDescription + def __init__( self, - name: str, - device_file: str, - entity_type: str, - entity_name: str, + description: OneWireEntityDescription, + device_id: str, device_info: DeviceInfo, - default_disabled: bool, - unique_id: str, + device_file: str, + name: str, ) -> None: """Initialize the entity.""" - self._name = f"{name} {entity_name or entity_type.capitalize()}" + self.entity_description = description + self._attr_unique_id = f"/{device_id}/{description.key}" + self._attr_device_info = device_info + self._attr_name = name self._device_file = device_file - self._entity_type = entity_type - self._device_class = SENSOR_TYPES[entity_type][1] - self._unit_of_measurement = SENSOR_TYPES[entity_type][0] - self._device_info = device_info self._state: StateType = None self._value_raw: float | None = None - self._default_disabled = default_disabled - self._unique_id = unique_id - - @property - def name(self) -> str | None: - """Return the name of the entity.""" - return self._name - - @property - def device_class(self) -> str | None: - """Return the class of this device.""" - return self._device_class @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the entity.""" - return {"device_file": self._device_file, "raw_value": self._value_raw} - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - return self._device_info - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return not self._default_disabled + return { + "device_file": self._device_file, + "raw_value": self._value_raw, + } class OneWireProxyEntity(OneWireBaseEntity): @@ -82,22 +59,20 @@ class OneWireProxyEntity(OneWireBaseEntity): def __init__( self, + description: OneWireEntityDescription, device_id: str, - device_name: str, device_info: DeviceInfo, - entity_path: str, - entity_specs: DeviceComponentDescription, + device_file: str, + name: str, owproxy: protocol._Proxy, ) -> None: """Initialize the sensor.""" super().__init__( - name=device_name, - device_file=entity_path, - entity_type=entity_specs["type"], - entity_name=entity_specs["name"], + description=description, + device_id=device_id, device_info=device_info, - default_disabled=entity_specs.get("default_disabled", False), - unique_id=f"/{device_id}/{entity_specs['path']}", + device_file=device_file, + name=name, ) self._owproxy = owproxy @@ -118,13 +93,9 @@ class OneWireProxyEntity(OneWireBaseEntity): _LOGGER.error("Owserver failure in read(), got: %s", exc) self._state = None else: - if self._entity_type == SENSOR_TYPE_COUNT: + if self.entity_description.read_mode == READ_MODE_INT: self._state = int(self._value_raw) - elif self._entity_type in [ - SENSOR_TYPE_SENSED, - SWITCH_TYPE_LATCH, - SWITCH_TYPE_PIO, - ]: + elif self.entity_description.read_mode == READ_MODE_BOOL: self._state = int(self._value_raw) == 1 else: self._state = round(self._value_raw, 1) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 3b63f551f98..b1f08b864b1 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -2,147 +2,218 @@ from __future__ import annotations import asyncio +import copy +from dataclasses import dataclass import logging import os from types import MappingProxyType from typing import Any from pi1wire import InvalidCRCException, OneWireInterface, UnsupportResponseException -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_HOST, CONF_PORT, CONF_TYPE +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_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + CONF_TYPE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + LIGHT_LUX, + PERCENTAGE, + PRESSURE_MBAR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from .const import ( CONF_MOUNT_DIR, CONF_NAMES, CONF_TYPE_OWSERVER, CONF_TYPE_SYSBUS, - DEFAULT_OWSERVER_PORT, - DEFAULT_SYSBUS_MOUNT_DIR, DOMAIN, - SENSOR_TYPE_COUNT, - SENSOR_TYPE_CURRENT, - SENSOR_TYPE_HUMIDITY, - SENSOR_TYPE_ILLUMINANCE, - SENSOR_TYPE_MOISTURE, - SENSOR_TYPE_PRESSURE, - SENSOR_TYPE_TEMPERATURE, - SENSOR_TYPE_VOLTAGE, - SENSOR_TYPE_WETNESS, + PRESSURE_CBAR, + READ_MODE_FLOAT, + READ_MODE_INT, +) +from .onewire_entities import ( + OneWireBaseEntity, + OneWireEntityDescription, + OneWireProxyEntity, ) -from .model import DeviceComponentDescription -from .onewire_entities import OneWireBaseEntity, OneWireProxyEntity from .onewirehub import OneWireHub + +@dataclass +class OneWireSensorEntityDescription(OneWireEntityDescription, SensorEntityDescription): + """Class describing OneWire sensor entities.""" + + +SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION = OneWireSensorEntityDescription( + key="temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, +) + _LOGGER = logging.getLogger(__name__) -DEVICE_SENSORS: dict[str, list[DeviceComponentDescription]] = { - # Family : { SensorType: owfs path } - "10": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "12": [ - { - "path": "TAI8570/temperature", - "name": "Temperature", - "type": SENSOR_TYPE_TEMPERATURE, - "default_disabled": True, - }, - { - "path": "TAI8570/pressure", - "name": "Pressure", - "type": SENSOR_TYPE_PRESSURE, - "default_disabled": True, - }, - ], - "22": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "26": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE}, - { - "path": "humidity", - "name": "Humidity", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "HIH3600/humidity", - "name": "Humidity HIH3600", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "HIH4000/humidity", - "name": "Humidity HIH4000", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "HIH5030/humidity", - "name": "Humidity HIH5030", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "HTM1735/humidity", - "name": "Humidity HTM1735", - "type": SENSOR_TYPE_HUMIDITY, - "default_disabled": True, - }, - { - "path": "B1-R1-A/pressure", - "name": "Pressure", - "type": SENSOR_TYPE_PRESSURE, - "default_disabled": True, - }, - { - "path": "S3-R1-A/illuminance", - "name": "Illuminance", - "type": SENSOR_TYPE_ILLUMINANCE, - "default_disabled": True, - }, - { - "path": "VAD", - "name": "Voltage VAD", - "type": SENSOR_TYPE_VOLTAGE, - "default_disabled": True, - }, - { - "path": "VDD", - "name": "Voltage VDD", - "type": SENSOR_TYPE_VOLTAGE, - "default_disabled": True, - }, - { - "path": "IAD", - "name": "Current", - "type": SENSOR_TYPE_CURRENT, - "default_disabled": True, - }, - ], - "28": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "3B": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "42": [ - {"path": "temperature", "name": "Temperature", "type": SENSOR_TYPE_TEMPERATURE} - ], - "1D": [ - {"path": "counter.A", "name": "Counter A", "type": SENSOR_TYPE_COUNT}, - {"path": "counter.B", "name": "Counter B", "type": SENSOR_TYPE_COUNT}, - ], - "EF": [], # "HobbyBoard": special - "7E": [], # "EDS": special + +DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { + "10": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "12": ( + OneWireSensorEntityDescription( + key="TAI8570/temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + entity_registry_enabled_default=False, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="TAI8570/pressure", + device_class=DEVICE_CLASS_PRESSURE, + entity_registry_enabled_default=False, + name="Pressure", + native_unit_of_measurement=PRESSURE_MBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "26": ( + SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION, + OneWireSensorEntityDescription( + key="humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="HIH3600/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity HIH3600", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="HIH4000/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity HIH4000", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="HIH5030/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity HIH5030", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="HTM1735/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + entity_registry_enabled_default=False, + name="Humidity HTM1735", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="B1-R1-A/pressure", + device_class=DEVICE_CLASS_PRESSURE, + entity_registry_enabled_default=False, + name="Pressure", + native_unit_of_measurement=PRESSURE_MBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="S3-R1-A/illuminance", + device_class=DEVICE_CLASS_ILLUMINANCE, + entity_registry_enabled_default=False, + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="VAD", + device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, + name="Voltage VAD", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="VDD", + device_class=DEVICE_CLASS_VOLTAGE, + entity_registry_enabled_default=False, + name="Voltage VDD", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="IAD", + device_class=DEVICE_CLASS_CURRENT, + entity_registry_enabled_default=False, + name="Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + "28": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "3B": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "42": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), + "1D": ( + OneWireSensorEntityDescription( + key="counter.A", + name="Counter A", + native_unit_of_measurement="count", + read_mode=READ_MODE_INT, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + OneWireSensorEntityDescription( + key="counter.B", + name="Counter B", + native_unit_of_measurement="count", + read_mode=READ_MODE_INT, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + ), + "EF": (), # "HobbyBoard": special + "7E": (), # "EDS": special } DEVICE_SUPPORT_SYSBUS = ["10", "22", "28", "3B", "42"] @@ -151,98 +222,127 @@ DEVICE_SUPPORT_SYSBUS = ["10", "22", "28", "3B", "42"] # These can only be read by OWFS. Currently this driver only supports them # via owserver (network protocol) -HOBBYBOARD_EF: dict[str, list[DeviceComponentDescription]] = { - "HobbyBoards_EF": [ - { - "path": "humidity/humidity_corrected", - "name": "Humidity", - "type": SENSOR_TYPE_HUMIDITY, - }, - { - "path": "humidity/humidity_raw", - "name": "Humidity Raw", - "type": SENSOR_TYPE_HUMIDITY, - }, - { - "path": "humidity/temperature", - "name": "Temperature", - "type": SENSOR_TYPE_TEMPERATURE, - }, - ], - "HB_MOISTURE_METER": [ - { - "path": "moisture/sensor.0", - "name": "Moisture 0", - "type": SENSOR_TYPE_MOISTURE, - }, - { - "path": "moisture/sensor.1", - "name": "Moisture 1", - "type": SENSOR_TYPE_MOISTURE, - }, - { - "path": "moisture/sensor.2", - "name": "Moisture 2", - "type": SENSOR_TYPE_MOISTURE, - }, - { - "path": "moisture/sensor.3", - "name": "Moisture 3", - "type": SENSOR_TYPE_MOISTURE, - }, - ], +HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { + "HobbyBoards_EF": ( + OneWireSensorEntityDescription( + key="humidity/humidity_corrected", + device_class=DEVICE_CLASS_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="humidity/humidity_raw", + device_class=DEVICE_CLASS_HUMIDITY, + name="Humidity Raw", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="humidity/temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + "HB_MOISTURE_METER": ( + OneWireSensorEntityDescription( + key="moisture/sensor.0", + device_class=DEVICE_CLASS_PRESSURE, + name="Moisture 0", + native_unit_of_measurement=PRESSURE_CBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="moisture/sensor.1", + device_class=DEVICE_CLASS_PRESSURE, + name="Moisture 1", + native_unit_of_measurement=PRESSURE_CBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="moisture/sensor.2", + device_class=DEVICE_CLASS_PRESSURE, + name="Moisture 2", + native_unit_of_measurement=PRESSURE_CBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="moisture/sensor.3", + device_class=DEVICE_CLASS_PRESSURE, + name="Moisture 3", + native_unit_of_measurement=PRESSURE_CBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), } # 7E sensors are special sensors by Embedded Data Systems -EDS_SENSORS: dict[str, list[DeviceComponentDescription]] = { - "EDS0066": [ - { - "path": "EDS0066/temperature", - "name": "Temperature", - "type": SENSOR_TYPE_TEMPERATURE, - }, - { - "path": "EDS0066/pressure", - "name": "Pressure", - "type": SENSOR_TYPE_PRESSURE, - }, - ], - "EDS0068": [ - { - "path": "EDS0068/temperature", - "name": "Temperature", - "type": SENSOR_TYPE_TEMPERATURE, - }, - { - "path": "EDS0068/pressure", - "name": "Pressure", - "type": SENSOR_TYPE_PRESSURE, - }, - { - "path": "EDS0068/light", - "name": "Illuminance", - "type": SENSOR_TYPE_ILLUMINANCE, - }, - { - "path": "EDS0068/humidity", - "name": "Humidity", - "type": SENSOR_TYPE_HUMIDITY, - }, - ], +EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { + "EDS0066": ( + OneWireSensorEntityDescription( + key="EDS0066/temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="EDS0066/pressure", + device_class=DEVICE_CLASS_PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_MBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + "EDS0068": ( + OneWireSensorEntityDescription( + key="EDS0068/temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="EDS0068/pressure", + device_class=DEVICE_CLASS_PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_MBAR, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="EDS0068/light", + device_class=DEVICE_CLASS_ILLUMINANCE, + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + OneWireSensorEntityDescription( + key="EDS0068/humidity", + device_class=DEVICE_CLASS_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + read_mode=READ_MODE_FLOAT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAMES): {cv.string: cv.string}, - vol.Optional(CONF_MOUNT_DIR, default=DEFAULT_SYSBUS_MOUNT_DIR): cv.string, - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_OWSERVER_PORT): cv.port, - } -) - - def get_sensor_types(device_sub_type: str) -> dict[str, Any]: """Return the proper info array for the device type.""" if "HobbyBoard" in device_sub_type: @@ -252,30 +352,6 @@ def get_sensor_types(device_sub_type: str) -> dict[str, Any]: return DEVICE_SENSORS -async def async_setup_platform( - hass: HomeAssistant, - config: dict[str, Any], - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Old way of setting up 1-Wire platform.""" - _LOGGER.warning( - "Loading 1-Wire via platform setup is deprecated. " - "Please remove it from your configuration" - ) - - if config.get(CONF_HOST): - config[CONF_TYPE] = CONF_TYPE_OWSERVER - elif config[CONF_MOUNT_DIR] == DEFAULT_SYSBUS_MOUNT_DIR: - config[CONF_TYPE] = CONF_TYPE_SYSBUS - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -291,12 +367,12 @@ async def async_setup_entry( def get_entities( onewirehub: OneWireHub, config: MappingProxyType[str, Any] -) -> list[OneWireBaseEntity]: +) -> list[SensorEntity]: """Get a list of entities.""" if not onewirehub.devices: return [] - entities: list[OneWireBaseEntity] = [] + entities: list[SensorEntity] = [] device_names = {} if CONF_NAMES in config and isinstance(config[CONF_NAMES], dict): device_names = config[CONF_NAMES] @@ -326,32 +402,35 @@ def get_entities( ) continue device_info: DeviceInfo = { - "identifiers": {(DOMAIN, device_id)}, - "manufacturer": "Maxim Integrated", - "model": device_type, - "name": device_id, + ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, + ATTR_MANUFACTURER: "Maxim Integrated", + ATTR_MODEL: device_type, + ATTR_NAME: device_id, } - for entity_specs in get_sensor_types(device_sub_type)[family]: - if entity_specs["type"] == SENSOR_TYPE_MOISTURE: - s_id = entity_specs["path"].split(".")[1] + for description in get_sensor_types(device_sub_type)[family]: + if description.key.startswith("moisture/"): + s_id = description.key.split(".")[1] is_leaf = int( onewirehub.owproxy.read( f"{device_path}moisture/is_leaf.{s_id}" ).decode() ) if is_leaf: - entity_specs["type"] = SENSOR_TYPE_WETNESS - entity_specs["name"] = f"Wetness {s_id}" - entity_path = os.path.join( - os.path.split(device_path)[0], entity_specs["path"] + description = copy.deepcopy(description) + description.device_class = DEVICE_CLASS_HUMIDITY + description.native_unit_of_measurement = PERCENTAGE + description.name = f"Wetness {s_id}" + device_file = os.path.join( + os.path.split(device["path"])[0], description.key ) + name = f"{device_names.get(device_id, device_id)} {description.name}" entities.append( OneWireProxySensor( + description=description, device_id=device_id, - device_name=device_names.get(device_id, device_id), + device_file=device_file, device_info=device_info, - entity_path=entity_path, - entity_specs=entity_specs, + name=name, owproxy=onewirehub.owproxy, ) ) @@ -362,28 +441,32 @@ def get_entities( _LOGGER.debug("Initializing using SysBus %s", base_dir) for p1sensor in onewirehub.devices: family = p1sensor.mac_address[:2] - sensor_id = f"{family}-{p1sensor.mac_address[2:]}" + device_id = f"{family}-{p1sensor.mac_address[2:]}" if family not in DEVICE_SUPPORT_SYSBUS: _LOGGER.warning( "Ignoring unknown family (%s) of sensor found for device: %s", family, - sensor_id, + device_id, ) continue device_info = { - "identifiers": {(DOMAIN, sensor_id)}, - "manufacturer": "Maxim Integrated", - "model": family, - "name": sensor_id, + ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, + ATTR_MANUFACTURER: "Maxim Integrated", + ATTR_MODEL: family, + ATTR_NAME: device_id, } - device_file = f"/sys/bus/w1/devices/{sensor_id}/w1_slave" + description = SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION + device_file = f"/sys/bus/w1/devices/{device_id}/w1_slave" + name = f"{device_names.get(device_id, device_id)} {description.name}" entities.append( OneWireDirectSensor( - device_names.get(sensor_id, sensor_id), - device_file, - device_info, - p1sensor, + description=description, + device_id=device_id, + device_file=device_file, + device_info=device_info, + name=name, + owsensor=p1sensor, ) ) if not entities: @@ -399,17 +482,16 @@ def get_entities( class OneWireSensor(OneWireBaseEntity, SensorEntity): """Mixin for sensor specific attributes.""" - @property - def unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._unit_of_measurement + entity_description: OneWireSensorEntityDescription class OneWireProxySensor(OneWireProxyEntity, OneWireSensor): """Implementation of a 1-Wire sensor connected through owserver.""" + entity_description: OneWireSensorEntityDescription + @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the entity.""" return self._state @@ -419,25 +501,26 @@ class OneWireDirectSensor(OneWireSensor): def __init__( self, - name: str, - device_file: str, + description: OneWireSensorEntityDescription, + device_id: str, device_info: DeviceInfo, + device_file: str, + name: str, owsensor: OneWireInterface, ) -> None: """Initialize the sensor.""" super().__init__( - name, - device_file, - "temperature", - "Temperature", - device_info, - False, - device_file, + description=description, + device_id=device_id, + device_info=device_info, + device_file=device_file, + name=name, ) + self._attr_unique_id = device_file self._owsensor = owsensor @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the entity.""" return self._state @@ -471,5 +554,9 @@ class OneWireDirectSensor(OneWireSensor): InvalidCRCException, UnsupportResponseException, ) as ex: - _LOGGER.warning("Cannot read from sensor %s: %s", self._device_file, ex) + _LOGGER.warning( + "Cannot read from sensor %s: %s", + self._device_file, + ex, + ) self._state = None diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 228c8f9d78b..678f930901f 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -1,156 +1,167 @@ """Support for 1-Wire environment switches.""" from __future__ import annotations +from dataclasses import dataclass import logging import os from typing import Any -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + CONF_TYPE, +) 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, SWITCH_TYPE_LATCH, SWITCH_TYPE_PIO -from .model import DeviceComponentDescription -from .onewire_entities import OneWireBaseEntity, OneWireProxyEntity +from .const import CONF_TYPE_OWSERVER, DOMAIN, READ_MODE_BOOL +from .onewire_entities import OneWireEntityDescription, OneWireProxyEntity from .onewirehub import OneWireHub -DEVICE_SWITCHES: dict[str, list[DeviceComponentDescription]] = { - # Family : { owfs path } - "05": [ - { - "path": "PIO", - "name": "PIO", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - ], - "12": [ - { - "path": "PIO.A", - "name": "PIO A", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.B", - "name": "PIO B", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "latch.A", - "name": "Latch A", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.B", - "name": "Latch B", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - ], - "29": [ - { - "path": "PIO.0", - "name": "PIO 0", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.1", - "name": "PIO 1", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.2", - "name": "PIO 2", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.3", - "name": "PIO 3", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.4", - "name": "PIO 4", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.5", - "name": "PIO 5", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.6", - "name": "PIO 6", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "PIO.7", - "name": "PIO 7", - "type": SWITCH_TYPE_PIO, - "default_disabled": True, - }, - { - "path": "latch.0", - "name": "Latch 0", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.1", - "name": "Latch 1", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.2", - "name": "Latch 2", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.3", - "name": "Latch 3", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.4", - "name": "Latch 4", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.5", - "name": "Latch 5", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.6", - "name": "Latch 6", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - { - "path": "latch.7", - "name": "Latch 7", - "type": SWITCH_TYPE_LATCH, - "default_disabled": True, - }, - ], + +@dataclass +class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescription): + """Class describing OneWire switch entities.""" + + +DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { + "05": ( + OneWireSwitchEntityDescription( + key="PIO", + entity_registry_enabled_default=False, + name="PIO", + 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, + ), + ), + "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, + ), + ), } LOGGER = logging.getLogger(__name__) @@ -170,12 +181,12 @@ async def async_setup_entry( async_add_entities(entities, True) -def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: +def get_entities(onewirehub: OneWireHub) -> list[SwitchEntity]: """Get a list of entities.""" if not onewirehub.devices: return [] - entities: list[OneWireBaseEntity] = [] + entities: list[SwitchEntity] = [] for device in onewirehub.devices: family = device["family"] @@ -186,22 +197,23 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: continue device_info: DeviceInfo = { - "identifiers": {(DOMAIN, device_id)}, - "manufacturer": "Maxim Integrated", - "model": device_type, - "name": device_id, + ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, + ATTR_MANUFACTURER: "Maxim Integrated", + ATTR_MODEL: device_type, + ATTR_NAME: device_id, } - for entity_specs in DEVICE_SWITCHES[family]: - entity_path = os.path.join( - os.path.split(device["path"])[0], entity_specs["path"] + for description in DEVICE_SWITCHES[family]: + device_file = os.path.join( + os.path.split(device["path"])[0], description.key ) + name = f"{device_id} {description.name}" entities.append( OneWireProxySwitch( + description=description, device_id=device_id, - device_name=device_id, + device_file=device_file, device_info=device_info, - entity_path=entity_path, - entity_specs=entity_specs, + name=name, owproxy=onewirehub.owproxy, ) ) @@ -212,6 +224,8 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBaseEntity]: class OneWireProxySwitch(OneWireProxyEntity, SwitchEntity): """Implementation of a 1-Wire switch.""" + entity_description: OneWireSwitchEntityDescription + @property def is_on(self) -> bool: """Return true if sensor is on.""" diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 5c44cdf1750..67bec21e123 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_per_platform +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_RTSP_TRANSPORT, @@ -31,7 +32,7 @@ from .const import ( from .device import ONVIFDevice -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ONVIF component.""" # Import from yaml configs = {} diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 0e95d24ef78..bb7cffa86f9 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,12 +1,12 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" -import asyncio +from __future__ import annotations from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame from onvif.exceptions import ONVIFError import voluptuous as vol from yarl import URL +from homeassistant.components import ffmpeg from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, DATA_FFMPEG from homeassistant.const import HTTP_BASIC_AUTHENTICATION @@ -120,7 +120,9 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): """Return the stream source.""" return self._stream_uri - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" image = None @@ -137,15 +139,12 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ) if image is None: - ffmpeg = ImageFrame(self.hass.data[DATA_FFMPEG].binary) - image = await asyncio.shield( - ffmpeg.get_image( - self._stream_uri, - output_format=IMAGE_JPEG, - extra_cmd=self.device.config_entry.options.get( - CONF_EXTRA_ARGUMENTS - ), - ) + return await ffmpeg.async_get_image( + self.hass, + self._stream_uri, + extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS), + width=width, + height=height, ) return image diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 87b68508fa1..9ebf87a4132 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -130,6 +130,7 @@ class ONVIFDevice: err, ) self.available = False + await self.device.close() except Fault as err: LOGGER.error( "Couldn't connect to camera '%s', please verify " diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 641497f5204..a7faa60cdcd 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -2,11 +2,7 @@ "domain": "onvif", "name": "ONVIF", "documentation": "https://www.home-assistant.io/integrations/onvif", - "requirements": [ - "onvif-zeep-async==1.0.0", - "WSDiscovery==2.0.0", - "zeep[async]==4.0.0" - ], + "requirements": ["onvif-zeep-async==1.2.0", "WSDiscovery==2.0.0"], "dependencies": ["ffmpeg"], "codeowners": ["@hunterjm"], "config_flow": true, diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 1c5766e3969..5c31644ba19 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -44,7 +44,7 @@ class ONVIFSensor(ONVIFBaseEntity, SensorEntity): super().__init__(device) @property - def state(self) -> None | str | int | float: + def native_value(self) -> None | str | int | float: """Return the state of the entity.""" return self.device.events.get_uid(self.uid).value @@ -59,7 +59,7 @@ class ONVIFSensor(ONVIFBaseEntity, SensorEntity): return self.device.events.get_uid(self.uid).device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" return self.device.events.get_uid(self.uid).unit_of_measurement diff --git a/homeassistant/components/onvif/translations/cs.json b/homeassistant/components/onvif/translations/cs.json index 49e7dde324a..4ddb2091cc3 100644 --- a/homeassistant/components/onvif/translations/cs.json +++ b/homeassistant/components/onvif/translations/cs.json @@ -18,6 +18,15 @@ }, "title": "Konfigurace ov\u011b\u0159ov\u00e1n\u00ed" }, + "configure": { + "data": { + "host": "Hostitel", + "name": "Jm\u00e9no", + "password": "Heslo", + "port": "Port", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, "configure_profile": { "data": { "include": "Vytvo\u0159it entitu kamery" diff --git a/homeassistant/components/onvif/translations/hu.json b/homeassistant/components/onvif/translations/hu.json index e2b63a6c9d8..c43df53ae9f 100644 --- a/homeassistant/components/onvif/translations/hu.json +++ b/homeassistant/components/onvif/translations/hu.json @@ -53,7 +53,19 @@ "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" + } + } + }, + "options": { + "step": { + "onvif_devices": { + "data": { + "extra_arguments": "Extra FFMPEG opci\u00f3k", + "rtsp_transport": "RTSP sz\u00e1ll\u00edt\u00e1si mechanizmus" + }, + "title": "ONVIF eszk\u00f6z opci\u00f3i" } } } diff --git a/homeassistant/components/onvif/translations/zh-Hans.json b/homeassistant/components/onvif/translations/zh-Hans.json index 0a0b6db3d38..8ebde5a1bda 100644 --- a/homeassistant/components/onvif/translations/zh-Hans.json +++ b/homeassistant/components/onvif/translations/zh-Hans.json @@ -1,19 +1,69 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u5df2\u5728\u8fdb\u884c\u4e2d", + "no_h264": "\u65e0\u53ef\u7528\u7684 H264 \u76f4\u64ad\u6d41\u3002\u8bf7\u68c0\u67e5\u8be5\u8bbe\u5907\u4e0a\u7684\u914d\u7f6e\u6587\u4ef6\u3002", + "no_mac": "\u65e0\u6cd5\u4e3a ONVIF \u914d\u7f6e\u8bbe\u5907\u552f\u4e00 ID", + "onvif_error": "\u914d\u7f6e ONVIF \u8bbe\u5907\u65f6\u51fa\u9519\u3002\u68c0\u67e5\u65e5\u5fd7\u4ee5\u83b7\u53d6\u66f4\u591a\u4fe1\u606f\u3002" + }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25" }, "step": { "auth": { "data": { + "password": "\u5bc6\u7801", "username": "\u7528\u6237\u540d" - } + }, + "title": "\u914d\u7f6e\u8ba4\u8bc1\u4fe1\u606f" + }, + "configure": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + }, + "title": "\u914d\u7f6e ONVIF \u8bbe\u5907" + }, + "configure_profile": { + "data": { + "include": "\u521b\u5efa\u6444\u50cf\u673a\u5b9e\u4f53" + }, + "description": "\u4ee5 {resolution} \u5206\u8fa8\u7387\u521b\u5efa {profile} \u6444\u50cf\u673a\u5b9e\u4f53\uff1f", + "title": "\u914d\u7f6e \u914d\u7f6e\u6587\u4ef6" + }, + "device": { + "data": { + "host": "\u9009\u62e9\u5df2\u88ab\u53d1\u73b0\u7684 ONVIF \u8bbe\u5907" + }, + "title": "\u9009\u62e9 ONVIF \u8bbe\u5907" + }, + "manual_input": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0", + "port": "\u7aef\u53e3" + }, + "title": "\u914d\u7f6e ONVIF \u8bbe\u5907" + }, + "user": { + "data": { + "auto": "\u81ea\u52a8\u641c\u7d22" + }, + "description": "\u901a\u8fc7\u70b9\u51fb\u63d0\u4ea4\u6309\u94ae\uff0cHome Assistant \u5c06\u4f1a\u5c1d\u8bd5\u641c\u7d22\u60a8\u7684\u7f51\u7edc\u4e2d\u652f\u6301 Profile S \u7684 ONVIF \u8bbe\u5907\u3002\n\n\u9700\u8981\u6ce8\u610f\u7684\u662f\uff0c\u6709\u4e9b\u751f\u4ea7\u5546\u51fa\u5382\u65f6\u9ed8\u8ba4\u4f1a\u5c06 ONVIF \u529f\u80fd\u5173\u95ed\u3002\u8bf7\u786e\u8ba4\u60a8\u7684\u6444\u50cf\u5934\u5df2\u6253\u5f00\u8be5\u529f\u80fd\u3002", + "title": "\u914d\u7f6e ONVIF \u8bbe\u5907" } } }, "options": { "step": { "onvif_devices": { + "data": { + "extra_arguments": "\u9644\u52a0 FFmpeg \u53c2\u6570", + "rtsp_transport": "RTSP \u4f20\u8f93\u901a\u8baf\u534f\u8bae" + }, "title": "ONVIF \u8bbe\u5907\u9009\u9879" } } diff --git a/homeassistant/components/openerz/sensor.py b/homeassistant/components/openerz/sensor.py index 33305b677de..8a3c2c0460d 100644 --- a/homeassistant/components/openerz/sensor.py +++ b/homeassistant/components/openerz/sensor.py @@ -44,7 +44,7 @@ class OpenERZSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index 29eeceb232c..3459671c829 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -1,11 +1,17 @@ """Support for monitoring an OpenEVSE Charger.""" +from __future__ import annotations + import logging import openevsewifi from requests import RequestException import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_VARIABLES, @@ -18,21 +24,53 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "status": ["Charging Status", None, None], - "charge_time": ["Charge Time Elapsed", TIME_MINUTES, None], - "ambient_temp": ["Ambient Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "ir_temp": ["IR Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "rtc_temp": ["RTC Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "usage_session": ["Usage this Session", ENERGY_KILO_WATT_HOUR, None], - "usage_total": ["Total Usage", ENERGY_KILO_WATT_HOUR, None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="status", + name="Charging Status", + ), + SensorEntityDescription( + key="charge_time", + name="Charge Time Elapsed", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key="ambient_temp", + name="Ambient Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="ir_temp", + name="IR Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="rtc_temp", + name="RTC Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="usage_session", + name="Usage this Session", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SensorEntityDescription( + key="usage_total", + name="Total Usage", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_MONITORED_VARIABLES, default=["status"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -40,63 +78,47 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the OpenEVSE sensor.""" - host = config.get(CONF_HOST) - monitored_variables = config.get(CONF_MONITORED_VARIABLES) + host = config[CONF_HOST] + monitored_variables = config[CONF_MONITORED_VARIABLES] charger = openevsewifi.Charger(host) - dev = [] - for variable in monitored_variables: - dev.append(OpenEVSESensor(variable, charger)) + entities = [ + OpenEVSESensor(charger, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - add_entities(dev, True) + add_entities(entities, True) class OpenEVSESensor(SensorEntity): """Implementation of an OpenEVSE sensor.""" - def __init__(self, sensor_type, charger): + def __init__(self, charger, description: SensorEntityDescription): """Initialize the sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type - self._state = None + self.entity_description = description self.charger = charger - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_device_class = SENSOR_TYPES[sensor_type][2] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this sensor.""" - return self._unit_of_measurement def update(self): """Get the monitored data from the charger.""" try: - if self.type == "status": - self._state = self.charger.getStatus() - elif self.type == "charge_time": - self._state = self.charger.getChargeTimeElapsed() / 60 - elif self.type == "ambient_temp": - self._state = self.charger.getAmbientTemperature() - elif self.type == "ir_temp": - self._state = self.charger.getIRTemperature() - elif self.type == "rtc_temp": - self._state = self.charger.getRTCTemperature() - elif self.type == "usage_session": - self._state = float(self.charger.getUsageSession()) / 1000 - elif self.type == "usage_total": - self._state = float(self.charger.getUsageTotal()) / 1000 + sensor_type = self.entity_description.key + if sensor_type == "status": + self._attr_native_value = self.charger.getStatus() + elif sensor_type == "charge_time": + self._attr_native_value = self.charger.getChargeTimeElapsed() / 60 + elif sensor_type == "ambient_temp": + self._attr_native_value = self.charger.getAmbientTemperature() + elif sensor_type == "ir_temp": + self._attr_native_value = self.charger.getIRTemperature() + elif sensor_type == "rtc_temp": + self._attr_native_value = self.charger.getRTCTemperature() + elif sensor_type == "usage_session": + self._attr_native_value = float(self.charger.getUsageSession()) / 1000 + elif sensor_type == "usage_total": + self._attr_native_value = float(self.charger.getUsageTotal()) / 1000 else: - self._state = "Unknown" + self._attr_native_value = "Unknown" except (RequestException, ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 8474cdab131..803123a88c3 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -73,7 +73,7 @@ class OpenexchangeratesSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 8f43c1e5e9b..280acab5d0e 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -62,12 +62,12 @@ class OpenHardwareMonitorDevice(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.value diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 122388b85b7..0502d6c6573 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -107,7 +107,7 @@ class OpenSkySensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -178,7 +178,7 @@ class OpenSkySensor(SensorEntity): return {ATTR_ATTRIBUTION: OPENSKY_ATTRIBUTION} @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return "flights" diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index 1d9904ea59f..28f139f188f 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -156,12 +156,12 @@ class OpenThermSensor(SensorEntity): return self._device_class @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/opentherm_gw/translations/hu.json b/homeassistant/components/opentherm_gw/translations/hu.json index 77112bd8929..3127dc523ce 100644 --- a/homeassistant/components/opentherm_gw/translations/hu.json +++ b/homeassistant/components/opentherm_gw/translations/hu.json @@ -24,7 +24,8 @@ "read_precision": "Pontoss\u00e1g olvas\u00e1sa", "set_precision": "Pontoss\u00e1g be\u00e1ll\u00edt\u00e1sa", "temporary_override_mode": "Ideiglenes be\u00e1ll\u00edt\u00e1s fel\u00fclb\u00edr\u00e1l\u00e1si m\u00f3dja" - } + }, + "description": "Opci\u00f3k az OpenTherm \u00e1tj\u00e1r\u00f3hoz" } } } diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index bcdd0b2ba40..5d165c498e2 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -24,14 +24,18 @@ 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.service import verify_domain_control from .const import ( + CONF_FROM_WINDOW, + CONF_TO_WINDOW, DATA_CLIENT, DATA_LISTENER, DATA_PROTECTION_WINDOW, DATA_UV, + DEFAULT_FROM_WINDOW, + DEFAULT_TO_WINDOW, DOMAIN, LOGGER, ) @@ -55,13 +59,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: websession = aiohttp_client.async_get_clientsession(hass) openuv = OpenUV( + config_entry, Client( config_entry.data[CONF_API_KEY], config_entry.data.get(CONF_LATITUDE, hass.config.latitude), config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation), session=websession, - ) + logger=LOGGER, + ), ) await openuv.async_update() hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = openuv @@ -134,15 +140,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class OpenUV: """Define a generic OpenUV object.""" - def __init__(self, client: Client) -> None: + def __init__(self, config_entry: ConfigEntry, client: Client) -> None: """Initialize.""" + self._config_entry = config_entry self.client = client self.data: dict[str, Any] = {} async def async_update_protection_data(self) -> None: """Update binary sensor (protection window) data.""" + low = self._config_entry.options.get(CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW) + high = self._config_entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW) + try: - resp = await self.client.uv_protection_window() + resp = await self.client.uv_protection_window(low=low, high=high) self.data[DATA_PROTECTION_WINDOW] = resp["result"] except OpenUvError as err: LOGGER.error("Error during protection data update: %s", err) @@ -166,14 +176,14 @@ class OpenUV: class OpenUvEntity(Entity): """Define a generic OpenUV entity.""" - def __init__(self, openuv: OpenUV, sensor_type: str) -> None: + def __init__(self, openuv: OpenUV, description: EntityDescription) -> None: """Initialize.""" self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._attr_should_poll = False self._attr_unique_id = ( - f"{openuv.client.latitude}_{openuv.client.longitude}_{sensor_type}" + f"{openuv.client.latitude}_{openuv.client.longitude}_{description.key}" ) - self._sensor_type = sensor_type + self.entity_description = description self.openuv = openuv async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 12b1f0c82af..a632d212abd 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -1,11 +1,14 @@ """Support for OpenUV binary sensors.""" -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime, utcnow -from . import OpenUV, OpenUvEntity +from . import OpenUvEntity from .const import ( DATA_CLIENT, DATA_PROTECTION_WINDOW, @@ -19,7 +22,11 @@ ATTR_PROTECTION_WINDOW_ENDING_UV = "end_uv" ATTR_PROTECTION_WINDOW_STARTING_TIME = "start_time" ATTR_PROTECTION_WINDOW_STARTING_UV = "start_uv" -BINARY_SENSORS = {TYPE_PROTECTION_WINDOW: ("Protection Window", "mdi:sunglasses")} +BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( + key=TYPE_PROTECTION_WINDOW, + name="Protection Window", + icon="mdi:sunglasses", +) async def async_setup_entry( @@ -27,25 +34,14 @@ async def async_setup_entry( ) -> None: """Set up an OpenUV sensor based on a config entry.""" openuv = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - - binary_sensors = [] - for kind, attrs in BINARY_SENSORS.items(): - name, icon = attrs - binary_sensors.append(OpenUvBinarySensor(openuv, kind, name, icon)) - - async_add_entities(binary_sensors, True) + async_add_entities( + [OpenUvBinarySensor(openuv, BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW)] + ) class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): """Define a binary sensor for OpenUV.""" - def __init__(self, openuv: OpenUV, sensor_type: str, name: str, icon: str) -> None: - """Initialize the sensor.""" - super().__init__(openuv, sensor_type) - - self._attr_icon = icon - self._attr_name = name - @callback def update_from_latest_data(self) -> None: """Update the state.""" @@ -62,7 +58,7 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): LOGGER.info("Skipping update due to missing data: %s", key) return - if self._sensor_type == TYPE_PROTECTION_WINDOW: + if self.entity_description.key == TYPE_PROTECTION_WINDOW: from_dt = parse_datetime(data["from_time"]) to_dt = parse_datetime(data["to_time"]) diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 3595b124053..e397bbf7f95 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -8,16 +8,24 @@ from pyopenuv.errors import OpenUvError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, ) +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import DOMAIN +from .const import ( + CONF_FROM_WINDOW, + CONF_TO_WINDOW, + DEFAULT_FROM_WINDOW, + DEFAULT_TO_WINDOW, + DOMAIN, +) class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -51,9 +59,11 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors if errors else {}, ) - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OpenUvOptionsFlowHandler: + """Define the config flow to handle options.""" + return OpenUvOptionsFlowHandler(config_entry) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -62,11 +72,7 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not user_input: return await self._show_form() - if user_input.get(CONF_LATITUDE): - identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" - else: - identifier = "Default Coordinates" - + identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" await self.async_set_unique_id(identifier) self._abort_if_unique_id_configured() @@ -79,3 +85,42 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._show_form({CONF_API_KEY: "invalid_api_key"}) return self.async_create_entry(title=identifier, data=user_input) + + +class OpenUvOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a OpenUV options flow.""" + + def __init__(self, config_entry: 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.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_FROM_WINDOW, + description={ + "suggested_value": self.config_entry.options.get( + CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW + ) + }, + ): vol.Coerce(float), + vol.Optional( + CONF_TO_WINDOW, + description={ + "suggested_value": self.config_entry.options.get( + CONF_FROM_WINDOW, DEFAULT_TO_WINDOW + ) + }, + ): vol.Coerce(float), + } + ), + ) diff --git a/homeassistant/components/openuv/const.py b/homeassistant/components/openuv/const.py index 683e349eb50..3b117fe37aa 100644 --- a/homeassistant/components/openuv/const.py +++ b/homeassistant/components/openuv/const.py @@ -4,11 +4,17 @@ import logging DOMAIN = "openuv" LOGGER = logging.getLogger(__package__) +CONF_FROM_WINDOW = "from_window" +CONF_TO_WINDOW = "to_window" + DATA_CLIENT = "data_client" DATA_LISTENER = "data_listener" DATA_PROTECTION_WINDOW = "protection_window" DATA_UV = "uv" +DEFAULT_FROM_WINDOW = 3.5 +DEFAULT_TO_WINDOW = 3.5 + TYPE_CURRENT_OZONE_LEVEL = "current_ozone_level" TYPE_CURRENT_UV_INDEX = "current_uv_index" TYPE_CURRENT_UV_LEVEL = "current_uv_level" diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index 842d4966805..24af3f3a3af 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==2.1.0"], + "requirements": ["pyopenuv==2.2.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 386527ebc3e..bb04bda4cb4 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,14 +1,14 @@ """Support for OpenUV sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import 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 -from . import OpenUV, OpenUvEntity +from . import OpenUvEntity from .const import ( DATA_CLIENT, DATA_UV, @@ -42,42 +42,67 @@ UV_LEVEL_HIGH = "High" UV_LEVEL_MODERATE = "Moderate" UV_LEVEL_LOW = "Low" -SENSORS = { - TYPE_CURRENT_OZONE_LEVEL: ("Current Ozone Level", "mdi:vector-triangle", "du"), - TYPE_CURRENT_UV_INDEX: ("Current UV Index", "mdi:weather-sunny", UV_INDEX), - TYPE_CURRENT_UV_LEVEL: ("Current UV Level", "mdi:weather-sunny", None), - TYPE_MAX_UV_INDEX: ("Max UV Index", "mdi:weather-sunny", UV_INDEX), - TYPE_SAFE_EXPOSURE_TIME_1: ( - "Skin Type 1 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_CURRENT_OZONE_LEVEL, + name="Current Ozone Level", + icon="mdi:vector-triangle", + native_unit_of_measurement="du", ), - TYPE_SAFE_EXPOSURE_TIME_2: ( - "Skin Type 2 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_CURRENT_UV_INDEX, + name="Current UV Index", + icon="mdi:weather-sunny", + native_unit_of_measurement=UV_INDEX, ), - TYPE_SAFE_EXPOSURE_TIME_3: ( - "Skin Type 3 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_CURRENT_UV_LEVEL, + name="Current UV Level", + icon="mdi:weather-sunny", ), - TYPE_SAFE_EXPOSURE_TIME_4: ( - "Skin Type 4 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_MAX_UV_INDEX, + name="Max UV Index", + icon="mdi:weather-sunny", + native_unit_of_measurement=UV_INDEX, ), - TYPE_SAFE_EXPOSURE_TIME_5: ( - "Skin Type 5 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_1, + name="Skin Type 1 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, ), - TYPE_SAFE_EXPOSURE_TIME_6: ( - "Skin Type 6 Safe Exposure Time", - "mdi:timer-outline", - TIME_MINUTES, + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_2, + name="Skin Type 2 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, ), -} + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_3, + name="Skin Type 3 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_4, + name="Skin Type 4 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_5, + name="Skin Type 5 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + ), + SensorEntityDescription( + key=TYPE_SAFE_EXPOSURE_TIME_6, + name="Skin Type 6 Safe Exposure Time", + icon="mdi:timer-outline", + native_unit_of_measurement=TIME_MINUTES, + ), +) async def async_setup_entry( @@ -85,28 +110,14 @@ async def async_setup_entry( ) -> None: """Set up a OpenUV sensor based on a config entry.""" openuv = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - - sensors = [] - for kind, attrs in SENSORS.items(): - name, icon, unit = attrs - sensors.append(OpenUvSensor(openuv, kind, name, icon, unit)) - - async_add_entities(sensors, True) + async_add_entities( + [OpenUvSensor(openuv, description) for description in SENSOR_DESCRIPTIONS] + ) class OpenUvSensor(OpenUvEntity, SensorEntity): """Define a binary sensor for OpenUV.""" - def __init__( - self, openuv: OpenUV, sensor_type: str, name: str, icon: str, unit: str | None - ) -> None: - """Initialize the sensor.""" - super().__init__(openuv, sensor_type) - - self._attr_icon = icon - self._attr_name = name - self._attr_unit_of_measurement = unit - @callback def update_from_latest_data(self) -> None: """Update the state.""" @@ -118,29 +129,29 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): self._attr_available = True - if self._sensor_type == TYPE_CURRENT_OZONE_LEVEL: - self._attr_state = data["ozone"] - elif self._sensor_type == TYPE_CURRENT_UV_INDEX: - self._attr_state = data["uv"] - elif self._sensor_type == TYPE_CURRENT_UV_LEVEL: + if self.entity_description.key == TYPE_CURRENT_OZONE_LEVEL: + self._attr_native_value = data["ozone"] + elif self.entity_description.key == TYPE_CURRENT_UV_INDEX: + self._attr_native_value = data["uv"] + elif self.entity_description.key == TYPE_CURRENT_UV_LEVEL: if data["uv"] >= 11: - self._attr_state = UV_LEVEL_EXTREME + self._attr_native_value = UV_LEVEL_EXTREME elif data["uv"] >= 8: - self._attr_state = UV_LEVEL_VHIGH + self._attr_native_value = UV_LEVEL_VHIGH elif data["uv"] >= 6: - self._attr_state = UV_LEVEL_HIGH + self._attr_native_value = UV_LEVEL_HIGH elif data["uv"] >= 3: - self._attr_state = UV_LEVEL_MODERATE + self._attr_native_value = UV_LEVEL_MODERATE else: - self._attr_state = UV_LEVEL_LOW - elif self._sensor_type == TYPE_MAX_UV_INDEX: - self._attr_state = data["uv_max"] + self._attr_native_value = UV_LEVEL_LOW + elif self.entity_description.key == TYPE_MAX_UV_INDEX: + self._attr_native_value = data["uv_max"] uv_max_time = parse_datetime(data["uv_max_time"]) if uv_max_time: self._attr_extra_state_attributes.update( {ATTR_MAX_UV_TIME: as_local(uv_max_time)} ) - elif self._sensor_type in ( + elif self.entity_description.key in ( TYPE_SAFE_EXPOSURE_TIME_1, TYPE_SAFE_EXPOSURE_TIME_2, TYPE_SAFE_EXPOSURE_TIME_3, @@ -148,6 +159,6 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, ): - self._attr_state = data["safe_exposure_time"][ - EXPOSURE_TYPE_MAP[self._sensor_type] + self._attr_native_value = data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[self.entity_description.key] ] diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index f865aa1e621..cd9ec36d93a 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -17,5 +17,16 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" } + }, + "options": { + "step": { + "init": { + "title": "Configure OpenUV", + "data": { + "from_window": "Starting UV index for the protection window", + "to_window": "Ending UV index for the protection window" + } + } + } } } diff --git a/homeassistant/components/openuv/translations/ca.json b/homeassistant/components/openuv/translations/ca.json index fbe163212f3..df55a79d1a5 100644 --- a/homeassistant/components/openuv/translations/ca.json +++ b/homeassistant/components/openuv/translations/ca.json @@ -17,5 +17,16 @@ "title": "Introdueix la teva informaci\u00f3" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "\u00cdndex UV d'inicialitzaci\u00f3 de la finestra protectora", + "to_window": "\u00cdndex UV de finalitzaci\u00f3 de la finestra protectora" + }, + "title": "Configura OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/de.json b/homeassistant/components/openuv/translations/de.json index 88f9e69a5b6..abc32f68f02 100644 --- a/homeassistant/components/openuv/translations/de.json +++ b/homeassistant/components/openuv/translations/de.json @@ -17,5 +17,16 @@ "title": "Gib deine Informationen ein" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Anfangs-UV-Index f\u00fcr das Schutzfenster", + "to_window": "End-UV-Index f\u00fcr das Schutzfenster" + }, + "title": "OpenUV konfigurieren" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/en.json b/homeassistant/components/openuv/translations/en.json index 6021c7a2030..92ca71cd46f 100644 --- a/homeassistant/components/openuv/translations/en.json +++ b/homeassistant/components/openuv/translations/en.json @@ -17,5 +17,16 @@ "title": "Fill in your information" } } + }, + "options": { + "step": { + "init": { + "data": { + "from_window": "Starting UV index for the protection window", + "to_window": "Ending UV index for the protection window" + }, + "title": "Configure OpenUV" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/openuv/translations/lt.json b/homeassistant/components/openuv/translations/lt.json new file mode 100644 index 00000000000..1546651d54a --- /dev/null +++ b/homeassistant/components/openuv/translations/lt.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "invalid_api_key": "Neteisingas API raktas" + }, + "step": { + "user": { + "data": { + "api_key": "API raktas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openweathermap/abstract_owm_sensor.py b/homeassistant/components/openweathermap/abstract_owm_sensor.py index ea12123b707..3c66ca50f3c 100644 --- a/homeassistant/components/openweathermap/abstract_owm_sensor.py +++ b/homeassistant/components/openweathermap/abstract_owm_sensor.py @@ -71,7 +71,7 @@ class AbstractOpenWeatherMapSensor(SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 39c50c3b941..3586f958a6a 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -68,7 +68,7 @@ class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor): self._weather_coordinator = weather_coordinator @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._weather_coordinator.data.get(self._sensor_type, None) @@ -91,7 +91,7 @@ class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor): self._weather_coordinator = weather_coordinator @property - def state(self): + def native_value(self): """Return the state of the device.""" forecasts = self._weather_coordinator.data.get(ATTR_API_FORECAST) if forecasts is not None and len(forecasts) > 0: diff --git a/homeassistant/components/oru/sensor.py b/homeassistant/components/oru/sensor.py index c17873aefea..dc2b7216534 100644 --- a/homeassistant/components/oru/sensor.py +++ b/homeassistant/components/oru/sensor.py @@ -42,7 +42,7 @@ class CurrentEnergyUsageSensor(SensorEntity): """Representation of the sensor.""" _attr_icon = SENSOR_ICON - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, meter): """Initialize the sensor.""" @@ -61,7 +61,7 @@ class CurrentEnergyUsageSensor(SensorEntity): return SENSOR_NAME @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 7aee9d99208..45bedb6b499 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -63,7 +63,7 @@ class TOTPSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index 79a8e6138eb..aa05c83ae76 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -100,36 +100,11 @@ class OVOEnergyEntity(CoordinatorEntity): coordinator: DataUpdateCoordinator, client: OVOEnergy, key: str, - name: str, - icon: str, ) -> None: """Initialize the OVO Energy entity.""" super().__init__(coordinator) self._client = client - self._key = key - self._name = name - self._icon = icon - self._available = True - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._key - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success and self._available + 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 7615a7011d3..cd84fa5a5d6 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -1,13 +1,30 @@ """Support for OVO Energy sensors.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta +from typing import Callable, Final from ovoenergy import OVODailyUsage from ovoenergy.ovoenergy import OVOEnergy -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_TIMESTAMP, + ENERGY_KILO_WATT_HOUR, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util from . import OVOEnergyDeviceEntity from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN @@ -15,9 +32,87 @@ from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN SCAN_INTERVAL = timedelta(seconds=300) PARALLEL_UPDATES = 4 +KEY_LAST_ELECTRICITY_COST: Final = "last_electricity_cost" +KEY_LAST_GAS_COST: Final = "last_gas_cost" + + +@dataclass +class OVOEnergySensorEntityDescription(SensorEntityDescription): + """Class describing System Bridge sensor entities.""" + + value: Callable[[OVODailyUsage], StateType] = round + + +SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( + OVOEnergySensorEntityDescription( + key="last_electricity_reading", + name="OVO Last Electricity Reading", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value=lambda usage: usage.electricity[-1].consumption, + ), + OVOEnergySensorEntityDescription( + key=KEY_LAST_ELECTRICITY_COST, + 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, + ), + OVOEnergySensorEntityDescription( + key="last_electricity_start_time", + name="OVO Last Electricity Start Time", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda usage: dt_util.as_utc(usage.electricity[-1].interval.start), + ), + OVOEnergySensorEntityDescription( + key="last_electricity_end_time", + name="OVO Last Electricity End Time", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda usage: dt_util.as_utc(usage.electricity[-1].interval.end), + ), +) + +SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( + OVOEnergySensorEntityDescription( + key="last_gas_reading", + name="OVO Last Gas Reading", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:gas-cylinder", + value=lambda usage: usage.gas[-1].consumption, + ), + OVOEnergySensorEntityDescription( + key=KEY_LAST_GAS_COST, + name="OVO Last Gas Cost", + device_class=DEVICE_CLASS_MONETARY, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:cash-multiple", + value=lambda usage: usage.gas[-1].consumption, + ), + OVOEnergySensorEntityDescription( + key="last_gas_start_time", + name="OVO Last Gas Start Time", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda usage: dt_util.as_utc(usage.gas[-1].interval.start), + ), + OVOEnergySensorEntityDescription( + key="last_gas_end_time", + name="OVO Last Gas End Time", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + value=lambda usage: dt_util.as_utc(usage.gas[-1].interval.end), + ), +) + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up OVO Energy sensor based on a config entry.""" coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ @@ -29,191 +124,45 @@ async def async_setup_entry( if coordinator.data: if coordinator.data.electricity: - entities.append(OVOEnergyLastElectricityReading(coordinator, client)) - entities.append( - OVOEnergyLastElectricityCost( - coordinator, - client, - coordinator.data.electricity[ - len(coordinator.data.electricity) - 1 - ].cost.currency_unit, - ) - ) + for description in SENSOR_TYPES_ELECTRICITY: + if description.key == KEY_LAST_ELECTRICITY_COST: + description.native_unit_of_measurement = ( + coordinator.data.electricity[-1].cost.currency_unit + ) + entities.append(OVOEnergySensor(coordinator, description, client)) if coordinator.data.gas: - entities.append(OVOEnergyLastGasReading(coordinator, client)) - entities.append( - OVOEnergyLastGasCost( - coordinator, - client, - coordinator.data.gas[ - len(coordinator.data.gas) - 1 - ].cost.currency_unit, - ) - ) + for description in SENSOR_TYPES_GAS: + if description.key == KEY_LAST_GAS_COST: + description.native_unit_of_measurement = coordinator.data.gas[ + -1 + ].cost.currency_unit + entities.append(OVOEnergySensor(coordinator, description, client)) async_add_entities(entities, True) class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): - """Defines a OVO Energy sensor.""" + """Define a OVO Energy sensor.""" + + coordinator: DataUpdateCoordinator + entity_description: OVOEnergySensorEntityDescription def __init__( self, coordinator: DataUpdateCoordinator, + description: OVOEnergySensorEntityDescription, client: OVOEnergy, - key: str, - name: str, - icon: str, - unit_of_measurement: str = "", ) -> None: - """Initialize OVO Energy sensor.""" - self._unit_of_measurement = unit_of_measurement - - super().__init__(coordinator, client, key, name, icon) - - @property - def unit_of_measurement(self) -> str: - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - -class OVOEnergyLastElectricityReading(OVOEnergySensor): - """Defines a OVO Energy last reading sensor.""" - - def __init__(self, coordinator: DataUpdateCoordinator, client: OVOEnergy) -> None: - """Initialize OVO Energy sensor.""" - + """Initialize.""" super().__init__( coordinator, client, - f"{client.account_id}_last_electricity_reading", - "OVO Last Electricity Reading", - "mdi:flash", - "kWh", + f"{DOMAIN}_{client.account_id}_{description.key}", ) + self.entity_description = description @property - def state(self) -> str: - """Return the state of the sensor.""" + def native_value(self) -> StateType: + """Return the state.""" usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.electricity: - return None - return usage.electricity[-1].consumption - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.electricity: - return None - return { - "start_time": usage.electricity[-1].interval.start, - "end_time": usage.electricity[-1].interval.end, - } - - -class OVOEnergyLastGasReading(OVOEnergySensor): - """Defines a OVO Energy last reading sensor.""" - - def __init__(self, coordinator: DataUpdateCoordinator, client: OVOEnergy) -> None: - """Initialize OVO Energy sensor.""" - - super().__init__( - coordinator, - client, - f"{DOMAIN}_{client.account_id}_last_gas_reading", - "OVO Last Gas Reading", - "mdi:gas-cylinder", - "kWh", - ) - - @property - def state(self) -> str: - """Return the state of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.gas: - return None - return usage.gas[-1].consumption - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.gas: - return None - return { - "start_time": usage.gas[-1].interval.start, - "end_time": usage.gas[-1].interval.end, - } - - -class OVOEnergyLastElectricityCost(OVOEnergySensor): - """Defines a OVO Energy last cost sensor.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, client: OVOEnergy, currency: str - ) -> None: - """Initialize OVO Energy sensor.""" - super().__init__( - coordinator, - client, - f"{DOMAIN}_{client.account_id}_last_electricity_cost", - "OVO Last Electricity Cost", - "mdi:cash-multiple", - currency, - ) - - @property - def state(self) -> str: - """Return the state of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.electricity: - return None - return usage.electricity[-1].cost.amount - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.electricity: - return None - return { - "start_time": usage.electricity[-1].interval.start, - "end_time": usage.electricity[-1].interval.end, - } - - -class OVOEnergyLastGasCost(OVOEnergySensor): - """Defines a OVO Energy last cost sensor.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, client: OVOEnergy, currency: str - ) -> None: - """Initialize OVO Energy sensor.""" - super().__init__( - coordinator, - client, - f"{DOMAIN}_{client.account_id}_last_gas_cost", - "OVO Last Gas Cost", - "mdi:cash-multiple", - currency, - ) - - @property - def state(self) -> str: - """Return the state of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.gas: - return None - return usage.gas[-1].cost.amount - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - usage: OVODailyUsage = self.coordinator.data - if usage is None or not usage.gas: - return None - return { - "start_time": usage.gas[-1].interval.start, - "end_time": usage.gas[-1].interval.end, - } + return self.entity_description.value(usage) diff --git a/homeassistant/components/ovo_energy/translations/hu.json b/homeassistant/components/ovo_energy/translations/hu.json index 143d1a8dc18..2c794b5cd9d 100644 --- a/homeassistant/components/ovo_energy/translations/hu.json +++ b/homeassistant/components/ovo_energy/translations/hu.json @@ -19,6 +19,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "\u00c1ll\u00edtson be egy OVO Energy p\u00e9ld\u00e1nyt az energiafelhaszn\u00e1l\u00e1s el\u00e9r\u00e9s\u00e9hez.", "title": "OVO Energy azonos\u00edt\u00f3 megad\u00e1sa" } } diff --git a/homeassistant/components/ovo_energy/translations/zh-Hans.json b/homeassistant/components/ovo_energy/translations/zh-Hans.json index a7477e8c370..cf7a799531d 100644 --- a/homeassistant/components/ovo_energy/translations/zh-Hans.json +++ b/homeassistant/components/ovo_energy/translations/zh-Hans.json @@ -4,9 +4,16 @@ "cannot_connect": "\u8fde\u63a5\u5931\u8d25", "invalid_auth": "\u9a8c\u8bc1\u7801\u9519\u8bef" }, + "flow_title": "{username}", "step": { + "reauth": { + "data": { + "password": "\u5bc6\u7801" + } + }, "user": { "data": { + "password": "\u5bc6\u7801", "username": "\u7528\u6237\u540d" } } diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index fc84a8ac7b0..c3c23ea6741 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -150,7 +150,7 @@ async def async_setup_entry( # noqa: C901 # The actual removal action of a Z-Wave node is reported as instance event # Only when this event is detected we cleanup the device and entities from hass # Note: Find a more elegant way of doing this, e.g. a notification of this event from OZW - if event in ["removenode", "removefailednode"] and "Node" in event_data: + if event in ("removenode", "removefailednode") and "Node" in event_data: removed_nodes.append(event_data["Node"]) @callback @@ -160,9 +160,7 @@ async def async_setup_entry( # noqa: C901 node_id = value.node.node_id # Filter out CommandClasses we're definitely not interested in. - if value.command_class in [ - CommandClass.MANUFACTURER_SPECIFIC, - ]: + if value.command_class in (CommandClass.MANUFACTURER_SPECIFIC,): return _LOGGER.debug( @@ -213,10 +211,10 @@ async def async_setup_entry( # noqa: C901 value.command_class, ) # Handle a scene activation message - if value.command_class in [ + if value.command_class in ( CommandClass.SCENE_ACTIVATION, CommandClass.CENTRAL_SCENE, - ]: + ): async_handle_scene_activated(hass, value) return diff --git a/homeassistant/components/ozw/sensor.py b/homeassistant/components/ozw/sensor.py index 3c3d4c3ca36..97b7b01d4d4 100644 --- a/homeassistant/components/ozw/sensor.py +++ b/homeassistant/components/ozw/sensor.py @@ -88,11 +88,11 @@ class ZwaveSensorBase(ZWaveDeviceEntity, SensorEntity): def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" # We hide some of the more advanced sensors by default to not overwhelm users - if self.values.primary.command_class in [ + if self.values.primary.command_class in ( CommandClass.BASIC, CommandClass.INDICATOR, CommandClass.NOTIFICATION, - ]: + ): return False return True @@ -106,12 +106,12 @@ class ZWaveStringSensor(ZwaveSensorBase): """Representation of a Z-Wave sensor.""" @property - def state(self): + def native_value(self): """Return state of the sensor.""" return self.values.primary.value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement the value is expressed in.""" return self.values.primary.units @@ -125,12 +125,12 @@ class ZWaveNumericSensor(ZwaveSensorBase): """Representation of a Z-Wave sensor.""" @property - def state(self): + def native_value(self): """Return state of the sensor.""" return round(self.values.primary.value, 2) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement the value is expressed in.""" if self.values.primary.units == "C": return TEMP_CELSIUS @@ -144,7 +144,7 @@ class ZWaveListSensor(ZwaveSensorBase): """Representation of a Z-Wave list sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" # We use the id as value for backwards compatibility return self.values.primary.value["Selected_id"] diff --git a/homeassistant/components/ozw/translations/hu.json b/homeassistant/components/ozw/translations/hu.json index 70934bf3472..a43f234c909 100644 --- a/homeassistant/components/ozw/translations/hu.json +++ b/homeassistant/components/ozw/translations/hu.json @@ -6,6 +6,7 @@ "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.", + "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." }, "error": { diff --git a/homeassistant/components/ozw/websocket_api.py b/homeassistant/components/ozw/websocket_api.py index 708b9045b57..4b96c577bf2 100644 --- a/homeassistant/components/ozw/websocket_api.py +++ b/homeassistant/components/ozw/websocket_api.py @@ -122,7 +122,7 @@ def _get_config_params(node, *args): for param in raw_values: schema = {} - if param["type"] in ["Byte", "Int", "Short"]: + if param["type"] in ("Byte", "Int", "Short"): schema = vol.Schema( { vol.Required(param["label"], default=param["value"]): vol.All( diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py new file mode 100644 index 00000000000..5d649fa6d63 --- /dev/null +++ b/homeassistant/components/p1_monitor/__init__.py @@ -0,0 +1,91 @@ +"""The P1 Monitor integration.""" +from __future__ import annotations + +from typing import TypedDict + +from p1monitor import P1Monitor, Phases, Settings, SmartMeter + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DOMAIN, + LOGGER, + SCAN_INTERVAL, + SERVICE_PHASES, + SERVICE_SETTINGS, + SERVICE_SMARTMETER, +) + +PLATFORMS = (SENSOR_DOMAIN,) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up P1 Monitor from a config entry.""" + + coordinator = P1MonitorDataUpdateCoordinator(hass) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + await coordinator.p1monitor.close() + raise + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload P1 Monitor config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + coordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.p1monitor.close() + return unload_ok + + +class P1MonitorData(TypedDict): + """Class for defining data in dict.""" + + smartmeter: SmartMeter + phases: Phases + settings: Settings + + +class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): + """Class to manage fetching P1 Monitor data from single endpoint.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize global P1 Monitor data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.p1monitor = P1Monitor( + self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + + async def _async_update_data(self) -> P1MonitorData: + """Fetch data from P1 Monitor.""" + data: P1MonitorData = { + SERVICE_SMARTMETER: await self.p1monitor.smartmeter(), + SERVICE_PHASES: await self.p1monitor.phases(), + SERVICE_SETTINGS: await self.p1monitor.settings(), + } + + return data diff --git a/homeassistant/components/p1_monitor/config_flow.py b/homeassistant/components/p1_monitor/config_flow.py new file mode 100644 index 00000000000..9e9d695f5e9 --- /dev/null +++ b/homeassistant/components/p1_monitor/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for P1 Monitor integration.""" +from __future__ import annotations + +from typing import Any + +from p1monitor import P1Monitor, P1MonitorError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class P1MonitorFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for P1 Monitor.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + + errors = {} + + if user_input is not None: + session = async_get_clientsession(self.hass) + try: + async with P1Monitor( + host=user_input[CONF_HOST], session=session + ) as client: + await client.smartmeter() + except P1MonitorError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAME, default=self.hass.config.location_name + ): str, + vol.Required(CONF_HOST): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/p1_monitor/const.py b/homeassistant/components/p1_monitor/const.py new file mode 100644 index 00000000000..1af76d49176 --- /dev/null +++ b/homeassistant/components/p1_monitor/const.py @@ -0,0 +1,23 @@ +"""Constants for the P1 Monitor integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "p1_monitor" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=5) + +ATTR_ENTRY_TYPE: Final = "entry_type" +ENTRY_TYPE_SERVICE: Final = "service" + +SERVICE_SMARTMETER: Final = "smartmeter" +SERVICE_PHASES: Final = "phases" +SERVICE_SETTINGS: Final = "settings" + +SERVICES: dict[str, str] = { + SERVICE_SMARTMETER: "SmartMeter", + SERVICE_PHASES: "Phases", + SERVICE_SETTINGS: "Settings", +} diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json new file mode 100644 index 00000000000..00b50bb029b --- /dev/null +++ b/homeassistant/components/p1_monitor/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "p1_monitor", + "name": "P1 Monitor", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/p1_monitor", + "requirements": ["p1monitor==1.0.0"], + "codeowners": ["@klaasnicolaas"], + "quality_scale": "platinum", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py new file mode 100644 index 00000000000..ea18854f748 --- /dev/null +++ b/homeassistant/components/p1_monitor/sensor.py @@ -0,0 +1,287 @@ +"""Support for P1 Monitor sensors.""" +from __future__ import annotations + +from typing import Literal + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_NAME, + CURRENCY_EURO, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import P1MonitorDataUpdateCoordinator +from .const import ( + ATTR_ENTRY_TYPE, + DOMAIN, + ENTRY_TYPE_SERVICE, + SERVICE_PHASES, + SERVICE_SETTINGS, + SERVICE_SMARTMETER, + SERVICES, +) + +SENSORS: dict[ + Literal["smartmeter", "phases", "settings"], tuple[SensorEntityDescription, ...] +] = { + SERVICE_SMARTMETER: ( + SensorEntityDescription( + key="gas_consumption", + name="Gas Consumption", + entity_registry_enabled_default=False, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="energy_consumption_high", + name="Energy Consumption - High Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_consumption_low", + name="Energy Consumption - Low Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_production", + name="Power Production", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="energy_production_high", + name="Energy Production - High Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_production_low", + name="Energy Production - Low Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_tariff_period", + name="Energy Tariff Period", + icon="mdi:calendar-clock", + ), + ), + SERVICE_PHASES: ( + SensorEntityDescription( + key="voltage_phase_l1", + name="Voltage Phase L1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_phase_l2", + name="Voltage Phase L2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_phase_l3", + name="Voltage Phase L3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l1", + name="Current Phase L1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l2", + name="Current Phase L2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l3", + name="Current Phase L3", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l1", + name="Power Consumed Phase L1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l2", + name="Power Consumed Phase L2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l3", + name="Power Consumed Phase L3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l1", + name="Power Produced Phase L1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l2", + name="Power Produced Phase L2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l3", + name="Power Produced Phase L3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + SERVICE_SETTINGS: ( + SensorEntityDescription( + key="gas_consumption_price", + name="Gas Consumption Price", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_consumption_price_low", + name="Energy Consumption Price - Low", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_consumption_price_high", + name="Energy Consumption Price - High", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_production_price_low", + name="Energy Production Price - Low", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_production_price_high", + name="Energy Production Price - High", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up P1 Monitor Sensors based on a config entry.""" + async_add_entities( + P1MonitorSensorEntity( + coordinator=hass.data[DOMAIN][entry.entry_id], + description=description, + service_key=service_key, + name=entry.title, + service=SERVICES[service_key], + ) + for service_key, service_sensors in SENSORS.items() + for description in service_sensors + ) + + +class P1MonitorSensorEntity(CoordinatorEntity, SensorEntity): + """Defines an P1 Monitor sensor.""" + + coordinator: P1MonitorDataUpdateCoordinator + + def __init__( + self, + *, + coordinator: P1MonitorDataUpdateCoordinator, + description: SensorEntityDescription, + service_key: Literal["smartmeter", "phases", "settings"], + name: str, + service: str, + ) -> None: + """Initialize P1 Monitor sensor.""" + super().__init__(coordinator=coordinator) + self._service_key = service_key + + self.entity_id = f"{SENSOR_DOMAIN}.{name}_{description.key}" + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{service_key}_{description.key}" + ) + + self._attr_device_info = { + ATTR_IDENTIFIERS: { + (DOMAIN, f"{coordinator.config_entry.entry_id}_{service_key}") + }, + ATTR_NAME: service, + ATTR_MANUFACTURER: "P1 Monitor", + ATTR_ENTRY_TYPE: ENTRY_TYPE_SERVICE, + } + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + value = getattr( + self.coordinator.data[self._service_key], self.entity_description.key + ) + if isinstance(value, str): + return value.lower() + return value diff --git a/homeassistant/components/p1_monitor/strings.json b/homeassistant/components/p1_monitor/strings.json new file mode 100644 index 00000000000..fba7973528e --- /dev/null +++ b/homeassistant/components/p1_monitor/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up P1 Monitor to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "[%key:common::config_flow::data::name%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/ca.json b/homeassistant/components/p1_monitor/translations/ca.json new file mode 100644 index 00000000000..8d83cfd5026 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom" + }, + "description": "Configura la integraci\u00f3 de P1 Monitor amb Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/de.json b/homeassistant/components/p1_monitor/translations/de.json new file mode 100644 index 00000000000..31a19a7a195 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Richte den P1-Monitor zur Integration mit Home Assistant ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/en.json b/homeassistant/components/p1_monitor/translations/en.json new file mode 100644 index 00000000000..34b64082b43 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Set up P1 Monitor to integrate with Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/et.json b/homeassistant/components/p1_monitor/translations/et.json new file mode 100644 index 00000000000..96ab4e46491 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/et.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nimi" + }, + "description": "Seadista P1 -monitor Home Assistantiga sidumiseks." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/he.json b/homeassistant/components/p1_monitor/translations/he.json new file mode 100644 index 00000000000..fbd52ea83ec --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/he.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/nl.json b/homeassistant/components/p1_monitor/translations/nl.json new file mode 100644 index 00000000000..fbb81d70c5d --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam" + }, + "description": "Stel P1 Monitor in om te integreren met Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/no.json b/homeassistant/components/p1_monitor/translations/no.json new file mode 100644 index 00000000000..a6b967e4a46 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn" + }, + "description": "Sett opp P1 Monitor for \u00e5 integreres med Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/pl.json b/homeassistant/components/p1_monitor/translations/pl.json new file mode 100644 index 00000000000..5aacfb63336 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa" + }, + "description": "Skonfiguruj P1 Monitor, aby zintegrowa\u0107 go z Home Assistantem." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/ru.json b/homeassistant/components/p1_monitor/translations/ru.json new file mode 100644 index 00000000000..661cc1c8968 --- /dev/null +++ b/homeassistant/components/p1_monitor/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.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\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 P1 Monitor." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/zh-Hant.json b/homeassistant/components/p1_monitor/translations/zh-Hant.json new file mode 100644 index 00000000000..fafb7f9b7c2 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31" + }, + "description": "\u8a2d\u5b9a P1 Monitor \u4ee5\u6574\u5408\u81f3 Home Assistant\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index e187b7c18a5..ab63b535e80 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -1,7 +1,7 @@ """The Panasonic Viera integration.""" from functools import partial import logging -from urllib.request import HTTPError, URLError +from urllib.error import HTTPError, URLError from panasonic_viera import EncryptionRequired, Keys, RemoteControl, SOAPError import voluptuous as vol diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py index 42400e7348c..d1c6461de21 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Panasonic Viera TV integration.""" from functools import partial import logging -from urllib.request import URLError +from urllib.error import URLError from panasonic_viera import TV_TYPE_ENCRYPTED, RemoteControl, SOAPError import voluptuous as vol diff --git a/homeassistant/components/panasonic_viera/translations/hu.json b/homeassistant/components/panasonic_viera/translations/hu.json index cfc0be387d0..df520bb1ca5 100644 --- a/homeassistant/components/panasonic_viera/translations/hu.json +++ b/homeassistant/components/panasonic_viera/translations/hu.json @@ -22,7 +22,8 @@ "host": "IP c\u00edm", "name": "N\u00e9v" }, - "description": "Add meg a Panasonic Viera TV-hez tartoz\u00f3 IP c\u00edmet" + "description": "Add meg a Panasonic Viera TV-hez tartoz\u00f3 IP c\u00edmet", + "title": "A TV be\u00e1ll\u00edt\u00e1sa" } } } diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 4a68dd3356f..ec2c5f7512d 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -15,6 +15,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -100,7 +101,7 @@ def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_DISMISS, data)) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the persistent notification component.""" persistent_notifications: MutableMapping[str, MutableMapping] = OrderedDict() hass.data[DOMAIN] = {"notifications": persistent_notifications} diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 7641a75e9c6..ba1f0ced623 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -293,7 +293,7 @@ The following persons point at invalid users: return filtered -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the person component.""" entity_component = EntityComponent(_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() diff --git a/homeassistant/components/philips_js/device_trigger.py b/homeassistant/components/philips_js/device_trigger.py index 51efa643310..85b1a012860 100644 --- a/homeassistant/components/philips_js/device_trigger.py +++ b/homeassistant/components/philips_js/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for control of device.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -23,7 +25,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for device.""" triggers = [] triggers.append( diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 4f3ee5a9ab3..3bea3ff7337 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -2,7 +2,7 @@ "domain": "philips_js", "name": "Philips TV", "documentation": "https://www.home-assistant.io/integrations/philips_js", - "requirements": ["ha-philipsjs==2.7.4"], + "requirements": ["ha-philipsjs==2.7.5"], "codeowners": ["@elupus"], "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index ab9191b0f4a..5c679a4839a 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -174,11 +174,6 @@ class PiHoleEntity(CoordinatorEntity): self._name = name self._server_unique_id = server_unique_id - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return "mdi:pi-hole" - @property def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 3c322d324d3..5758c0e4145 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -29,6 +29,8 @@ async def async_setup_entry( class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): """Representation of a Pi-hole binary sensor.""" + _attr_icon = "mdi:pi-hole" + @property def name(self) -> str: """Return the name of the sensor.""" diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index f1871bf27c8..f1ec1c6efd6 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -1,6 +1,10 @@ """Constants for the pi_hole integration.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import PERCENTAGE DOMAIN = "pi_hole" @@ -25,28 +29,67 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) DATA_KEY_API = "api" DATA_KEY_COORDINATOR = "coordinator" -SENSOR_DICT = { - "ads_blocked_today": ["Ads Blocked Today", "ads", "mdi:close-octagon-outline"], - "ads_percentage_today": [ - "Ads Percentage Blocked Today", - PERCENTAGE, - "mdi:close-octagon-outline", - ], - "clients_ever_seen": ["Seen Clients", "clients", "mdi:account-outline"], - "dns_queries_today": [ - "DNS Queries Today", - "queries", - "mdi:comment-question-outline", - ], - "domains_being_blocked": ["Domains Blocked", "domains", "mdi:block-helper"], - "queries_cached": ["DNS Queries Cached", "queries", "mdi:comment-question-outline"], - "queries_forwarded": [ - "DNS Queries Forwarded", - "queries", - "mdi:comment-question-outline", - ], - "unique_clients": ["DNS Unique Clients", "clients", "mdi:account-outline"], - "unique_domains": ["DNS Unique Domains", "domains", "mdi:domain"], -} -SENSOR_LIST = list(SENSOR_DICT) +@dataclass +class PiHoleSensorEntityDescription(SensorEntityDescription): + """Describes PiHole sensor entity.""" + + icon: str = "mdi:pi-hole" + + +SENSOR_TYPES: tuple[PiHoleSensorEntityDescription, ...] = ( + PiHoleSensorEntityDescription( + key="ads_blocked_today", + name="Ads Blocked Today", + native_unit_of_measurement="ads", + icon="mdi:close-octagon-outline", + ), + PiHoleSensorEntityDescription( + key="ads_percentage_today", + name="Ads Percentage Blocked Today", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:close-octagon-outline", + ), + PiHoleSensorEntityDescription( + key="clients_ever_seen", + name="Seen Clients", + native_unit_of_measurement="clients", + icon="mdi:account-outline", + ), + PiHoleSensorEntityDescription( + key="dns_queries_today", + name="DNS Queries Today", + native_unit_of_measurement="queries", + icon="mdi:comment-question-outline", + ), + PiHoleSensorEntityDescription( + key="domains_being_blocked", + name="Domains Blocked", + native_unit_of_measurement="domains", + icon="mdi:block-helper", + ), + PiHoleSensorEntityDescription( + key="queries_cached", + name="DNS Queries Cached", + native_unit_of_measurement="queries", + icon="mdi:comment-question-outline", + ), + PiHoleSensorEntityDescription( + key="queries_forwarded", + name="DNS Queries Forwarded", + native_unit_of_measurement="queries", + icon="mdi:comment-question-outline", + ), + PiHoleSensorEntityDescription( + key="unique_clients", + name="DNS Unique Clients", + native_unit_of_measurement="clients", + icon="mdi:account-outline", + ), + PiHoleSensorEntityDescription( + key="unique_domains", + name="DNS Unique Domains", + native_unit_of_measurement="domains", + icon="mdi:domain", + ), +) diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 95aee56f7cc..0e231868647 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -18,8 +18,8 @@ from .const import ( DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN, - SENSOR_DICT, - SENSOR_LIST, + SENSOR_TYPES, + PiHoleSensorEntityDescription, ) @@ -34,10 +34,10 @@ async def async_setup_entry( hole_data[DATA_KEY_API], hole_data[DATA_KEY_COORDINATOR], name, - sensor_name, entry.entry_id, + description, ) - for sensor_name in SENSOR_LIST + for description in SENSOR_TYPES ] async_add_entities(sensors, True) @@ -45,51 +45,30 @@ async def async_setup_entry( class PiHoleSensor(PiHoleEntity, SensorEntity): """Representation of a Pi-hole sensor.""" + entity_description: PiHoleSensorEntityDescription + def __init__( self, api: Hole, coordinator: DataUpdateCoordinator, name: str, - sensor_name: str, server_unique_id: str, + description: PiHoleSensorEntityDescription, ) -> None: """Initialize a Pi-hole sensor.""" super().__init__(api, coordinator, name, server_unique_id) + self.entity_description = description - self._condition = sensor_name - - variable_info = SENSOR_DICT[sensor_name] - self._condition_name = variable_info[0] - self._unit_of_measurement = variable_info[1] - self._icon = variable_info[2] + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.name}" @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._name} {self._condition_name}" - - @property - def unique_id(self) -> str: - """Return the unique id of the sensor.""" - return f"{self._server_unique_id}/{self._condition_name}" - - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def state(self) -> Any: + def native_value(self) -> Any: """Return the state of the device.""" try: - return round(self.api.data[self._condition], 2) + return round(self.api.data[self.entity_description.key], 2) except TypeError: - return self.api.data[self._condition] + return self.api.data[self.entity_description.key] @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index b0c4b09c2e7..dc699beb26b 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -58,6 +58,8 @@ async def async_setup_entry( class PiHoleSwitch(PiHoleEntity, SwitchEntity): """Representation of a Pi-hole switch.""" + _attr_icon = "mdi:pi-hole" + @property def name(self) -> str: """Return the name of the switch.""" @@ -68,11 +70,6 @@ class PiHoleSwitch(PiHoleEntity, SwitchEntity): """Return the unique id of the switch.""" return f"{self._server_unique_id}/Switch" - @property - def icon(self) -> str: - """Icon to use in the frontend, if any.""" - return "mdi:pi-hole" - @property def is_on(self) -> bool: """Return if the service is on.""" diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 3a4d3582f9c..57f24180c03 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant @@ -30,7 +31,7 @@ async def async_setup_entry( return True -class PicnicSensor(CoordinatorEntity): +class PicnicSensor(SensorEntity, CoordinatorEntity): """The CoordinatorEntity subclass representing Picnic sensors.""" def __init__( @@ -49,7 +50,7 @@ class PicnicSensor(CoordinatorEntity): self._service_unique_id = config_entry.unique_id @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self.properties.get("unit") @@ -64,7 +65,7 @@ class PicnicSensor(CoordinatorEntity): return self._to_capitalized_name(self.sensor_type) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the entity.""" data_set = ( self.coordinator.data.get(self.properties["data_type"], {}) diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 02d56c890fe..5dbad2838bc 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -136,7 +136,7 @@ class CallRateDelayThrottle: def __init__(self, hass, delay_seconds: float) -> None: """Initialize the delay handler.""" self._delay = timedelta(seconds=max(0.0, delay_seconds)) - self._queue = [] + self._queue: list = [] self._active = False self._lock = threading.Lock() self._next_ts = dt_util.utcnow() diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 97458acd5fc..bc8135c5932 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -63,12 +63,12 @@ class PilightSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self._state diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index 9af16a1cacd..e3e37d4291e 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -75,11 +75,11 @@ class PlaatoSensor(PlaatoEntity, SensorEntity): return None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._sensor_data.sensors.get(self._sensor_type) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._sensor_data.get_unit_of_measurement(self._sensor_type) diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index e19d86e89ec..ac3c6e8f8f8 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -191,7 +191,7 @@ def browse_media( # noqa: C901 return BrowseMedia(**payload) try: - if media_content_type in ["server", None]: + if media_content_type in ("server", None): return server_payload(entity.plex_server) if media_content_type == "library": diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 1033c4286ac..2e89ede121d 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -256,7 +256,7 @@ class PlexMediaPlayer(MediaPlayerEntity): @property def _is_player_active(self): """Report if the client is playing media.""" - return self.state in [STATE_PLAYING, STATE_PAUSED] + return self.state in (STATE_PLAYING, STATE_PAUSED) @property def _active_media_plexapi_type(self): diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 8ca72e8fb83..0969967e673 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -62,7 +62,7 @@ class PlexSensor(SensorEntity): self._attr_name = NAME_FORMAT.format(plex_server.friendly_name) self._attr_should_poll = False self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" - self._attr_unit_of_measurement = "Watching" + self._attr_native_unit_of_measurement = "Watching" self._server = plex_server self.async_refresh_sensor = Debouncer( @@ -87,7 +87,7 @@ class PlexSensor(SensorEntity): async def _async_refresh_sensor(self): """Set instance object and trigger an entity state update.""" _LOGGER.debug("Refreshing sensor [%s]", self.unique_id) - self._attr_state = len(self._server.sensor_attributes) + self._attr_native_value = len(self._server.sensor_attributes) self.async_write_ha_state() @property @@ -128,7 +128,7 @@ class PlexLibrarySectionSensor(SensorEntity): self._attr_name = f"{self.server_name} Library - {plex_library_section.title}" self._attr_should_poll = False self._attr_unique_id = f"library-{self.server_id}-{plex_library_section.uuid}" - self._attr_unit_of_measurement = "Items" + self._attr_native_unit_of_measurement = "Items" async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -164,7 +164,7 @@ class PlexLibrarySectionSensor(SensorEntity): self.library_type, self.library_type ) - self._attr_state = self.library_section.totalViewSize( + self._attr_native_value = self.library_section.totalViewSize( libtype=primary_libtype, includeCollections=False ) for libtype in LIBRARY_ATTRIBUTE_TYPES.get(self.library_type, []): diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index dc05a727fee..12398edfd59 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -144,11 +144,11 @@ class PlexServer: config_entry_update_needed = False def _connect_with_token(): - available_servers = [ - (x.name, x.clientIdentifier) - for x in self.account.resources() - if "server" in x.provides and x.presence + all_servers = [ + x for x in self.account.resources() if "server" in x.provides ] + servers = [x for x in all_servers if x.presence] or all_servers + available_servers = [(x.name, x.clientIdentifier) for x in servers] if not available_servers: raise NoServersFound @@ -291,10 +291,10 @@ class PlexServer: media = self.fetch_item(rating_key) active_session.update_media(media) - if active_session.media_content_id != rating_key and state in [ + if active_session.media_content_id != rating_key and state in ( "playing", "paused", - ]: + ): await self.hass.async_add_executor_job(update_with_new_media) async_dispatcher_send( diff --git a/homeassistant/components/plex/translations/hu.json b/homeassistant/components/plex/translations/hu.json index 9168f070609..c0ecbe3e02c 100644 --- a/homeassistant/components/plex/translations/hu.json +++ b/homeassistant/components/plex/translations/hu.json @@ -10,8 +10,10 @@ }, "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", - "not_found": "A Plex szerver nem tal\u00e1lhat\u00f3" + "not_found": "A Plex szerver nem tal\u00e1lhat\u00f3", + "ssl_error": "SSL tan\u00fas\u00edtv\u00e1ny probl\u00e9ma" }, "flow_title": "{name} ({host})", "step": { @@ -22,7 +24,8 @@ "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "token": "Token (opcion\u00e1lis)", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" - } + }, + "title": "K\u00e9zi Plex konfigur\u00e1ci\u00f3" }, "select_server": { "data": { @@ -32,9 +35,13 @@ "title": "Plex-kiszolg\u00e1l\u00f3 kiv\u00e1laszt\u00e1sa" }, "user": { + "description": "Folytassa a [plex.tv] (https://plex.tv) oldalt a Plex szerver \u00f6sszekapcsol\u00e1s\u00e1hoz.", "title": "Plex Media Server" }, "user_advanced": { + "data": { + "setup_method": "Be\u00e1ll\u00edt\u00e1si m\u00f3dszer" + }, "title": "Plex Media Server" } } @@ -43,7 +50,9 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Hagyja figyelmen k\u00edv\u00fcl az \u00faj kezelt/megosztott felhaszn\u00e1l\u00f3kat", "ignore_plex_web_clients": "Plex Web kliensek figyelmen k\u00edv\u00fcl hagy\u00e1sa", + "monitored_users": "Megfigyelt felhaszn\u00e1l\u00f3k", "use_episode_art": "Haszn\u00e1lja az epiz\u00f3d bor\u00edt\u00f3j\u00e1t" }, "description": "Plex media lej\u00e1tsz\u00f3k be\u00e1ll\u00edt\u00e1sai" diff --git a/homeassistant/components/plex/translations/zh-Hans.json b/homeassistant/components/plex/translations/zh-Hans.json index 9cc02584789..02f548f2286 100644 --- a/homeassistant/components/plex/translations/zh-Hans.json +++ b/homeassistant/components/plex/translations/zh-Hans.json @@ -1,11 +1,45 @@ { "config": { + "abort": { + "already_configured": "\u6b64 Plex \u670d\u52a1\u5668\u5df2\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u5df2\u5728\u8fdb\u884c\u4e2d", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f", + "token_request_timeout": "\u83b7\u53d6\u4ee4\u724c\u8d85\u65f6", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "faulty_credentials": "\u6388\u6743\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u4ee4\u724c\u4fe1\u606f", + "host_or_token": "\u5fc5\u987b\u81f3\u5c11\u63d0\u4f9b\u4e00\u4e2a\u4e3b\u673a\u5730\u5740\u6216\u4ee4\u724c", + "not_found": "\u627e\u4e0d\u5230 Plex \u670d\u52a1\u5668", + "ssl_error": "SSL \u8bc1\u4e66\u9519\u8bef" + }, + "flow_title": "{name} ({host})", "step": { + "manual_setup": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66", + "token": "\u4ee4\u724c (\u53ef\u9009)", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66" + }, + "title": "\u624b\u52a8\u914d\u7f6e" + }, "select_server": { "data": { "server": "\u670d\u52a1\u5668" }, + "description": "\u6709\u591a\u4e2a\u53ef\u7528\u670d\u52a1\u5668\uff0c\u8bf7\u9009\u62e9\uff1a", "title": "\u9009\u62e9 Plex \u670d\u52a1\u5668" + }, + "user": { + "title": "Plex \u5a92\u4f53\u670d\u52a1\u5668" + }, + "user_advanced": { + "data": { + "setup_method": "\u8bbe\u7f6e\u65b9\u6cd5" + }, + "title": "Plex \u5a92\u4f53\u670d\u52a1\u5668" } } }, @@ -14,8 +48,10 @@ "plex_mp_settings": { "data": { "ignore_new_shared_users": "\u5ffd\u7565\u65b0\u589e\u7ba1\u7406/\u5171\u4eab\u4f7f\u7528\u8005", + "ignore_plex_web_clients": "\u5ffd\u7565 Plex Web \u5ba2\u6237\u7aef", "monitored_users": "\u53d7\u76d1\u89c6\u7684\u7528\u6237" - } + }, + "description": "Plex \u5a92\u4f53\u64ad\u653e\u5668\u9009\u9879" } } } diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 4152f9fdabd..854c2e6676c 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -272,12 +272,12 @@ class SmileSensor(SmileGateway, SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of this entity.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/plum_lightpad/__init__.py b/homeassistant/components/plum_lightpad/__init__.py index 9f69c8579a4..f92d087b79d 100644 --- a/homeassistant/components/plum_lightpad/__init__.py +++ b/homeassistant/components/plum_lightpad/__init__.py @@ -10,6 +10,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTAN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .utils import load_plum @@ -34,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["light"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Plum Lightpad Platform initialization.""" if DOMAIN not in config: return True diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 55ae4a524fc..f745bd562bd 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -50,7 +50,7 @@ class PocketCastsSensor(SensorEntity): return SENSOR_NAME @property - def state(self): + def native_value(self): """Return the sensor state.""" return self._state diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index dc34e1f9367..8d4ee69fca2 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -1,7 +1,14 @@ """Support for Minut Point sensors.""" +from __future__ import annotations + +from dataclasses import dataclass import logging -from homeassistant.components.sensor import DOMAIN, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, @@ -21,12 +28,48 @@ _LOGGER = logging.getLogger(__name__) DEVICE_CLASS_SOUND = "sound_level" -SENSOR_TYPES = { - DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS), - DEVICE_CLASS_PRESSURE: (None, 0, PRESSURE_HPA), - DEVICE_CLASS_HUMIDITY: (None, 1, PERCENTAGE), - DEVICE_CLASS_SOUND: ("mdi:ear-hearing", 1, SOUND_PRESSURE_WEIGHTED_DBA), -} + +@dataclass +class MinutPointRequiredKeysMixin: + """Mixin for required keys.""" + + precision: int + + +@dataclass +class MinutPointSensorEntityDescription( + SensorEntityDescription, MinutPointRequiredKeysMixin +): + """Describes MinutPoint sensor entity.""" + + +SENSOR_TYPES: tuple[MinutPointSensorEntityDescription, ...] = ( + MinutPointSensorEntityDescription( + key="temperature", + precision=1, + 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, + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + ), + MinutPointSensorEntityDescription( + key="sound", + precision=1, + device_class=DEVICE_CLASS_SOUND, + icon="mdi:ear-hearing", + native_unit_of_measurement=SOUND_PRESSURE_WEIGHTED_DBA, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -36,10 +79,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Discover and add a discovered sensor.""" client = hass.data[POINT_DOMAIN][config_entry.entry_id] async_add_entities( - ( - MinutPointSensor(client, device_id, sensor_type) - for sensor_type in SENSOR_TYPES - ), + [ + MinutPointSensor(client, device_id, description) + for description in SENSOR_TYPES + ], True, ) @@ -51,10 +94,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class MinutPointSensor(MinutPointEntity, SensorEntity): """The platform class required by Home Assistant.""" - def __init__(self, point_client, device_id, device_class): + entity_description: MinutPointSensorEntityDescription + + def __init__( + self, point_client, device_id, description: MinutPointSensorEntityDescription + ): """Initialize the sensor.""" - super().__init__(point_client, device_id, device_class) - self._device_prop = SENSOR_TYPES[device_class] + super().__init__(point_client, device_id, description.device_class) + self.entity_description = description async def _update_callback(self): """Update the value of the sensor.""" @@ -65,18 +112,8 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): self.async_write_ha_state() @property - def icon(self): - """Return the icon representation.""" - return self._device_prop[0] - - @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.value is None: return None - return round(self.value, self._device_prop[1]) - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._device_prop[2] + return round(self.value, self.entity_description.precision) diff --git a/homeassistant/components/point/translations/en_GB.json b/homeassistant/components/point/translations/en_GB.json new file mode 100644 index 00000000000..f348959d089 --- /dev/null +++ b/homeassistant/components/point/translations/en_GB.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL.", + "unknown_authorize_url_generation": "Unknown error generating an authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/translations/lt.json b/homeassistant/components/point/translations/lt.json new file mode 100644 index 00000000000..baf3fb1292d --- /dev/null +++ b/homeassistant/components/point/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "flow_impl": "Teik\u0117jas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index dd03111e85e..e9aeaca20f5 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -89,7 +89,7 @@ class PoolSenseSensor(PoolSenseEntity, SensorEntity): return f"PoolSense {SENSORS[self.info_type]['name']}" @property - def state(self): + def native_value(self): """State of the sensor.""" return self.coordinator.data[self.info_type] @@ -104,7 +104,7 @@ class PoolSenseSensor(PoolSenseEntity, SensorEntity): return SENSORS[self.info_type]["icon"] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement.""" return SENSORS[self.info_type]["unit"] diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index c86333cb9f8..b2cd48df276 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -9,8 +9,6 @@ POWERWALL_API_CHANGED = "api_changed" UPDATE_INTERVAL = 30 ATTR_FREQUENCY = "frequency" -ATTR_ENERGY_EXPORTED = "energy_exported_(in_kW)" -ATTR_ENERGY_IMPORTED = "energy_imported_(in_kW)" ATTR_INSTANT_AVERAGE_VOLTAGE = "instant_average_voltage" ATTR_INSTANT_TOTAL_CURRENT = "instant_total_current" ATTR_IS_ACTIVE = "is_active" diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index d536c776bf0..940dcad8647 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -3,17 +3,21 @@ import logging from tesla_powerwall import MeterType -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_KILO_WATT, ) from .const import ( - ATTR_ENERGY_EXPORTED, - ATTR_ENERGY_IMPORTED, ATTR_FREQUENCY, ATTR_INSTANT_AVERAGE_VOLTAGE, ATTR_INSTANT_TOTAL_CURRENT, @@ -29,6 +33,11 @@ from .const import ( ) from .entity import PowerWallEntity +_METER_DIRECTION_EXPORT = "export" +_METER_DIRECTION_IMPORT = "import" +_METER_DIRECTIONS = [_METER_DIRECTION_EXPORT, _METER_DIRECTION_IMPORT] + + _LOGGER = logging.getLogger(__name__) @@ -55,6 +64,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): powerwalls_serial_numbers, ) ) + for meter_direction in _METER_DIRECTIONS: + entities.append( + PowerWallEnergyDirectionSensor( + meter, + coordinator, + site_info, + status, + device_type, + powerwalls_serial_numbers, + meter_direction, + ) + ) entities.append( PowerWallChargeSensor( @@ -69,7 +90,7 @@ class PowerWallChargeSensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall charge sensor.""" _attr_name = "Powerwall Charge" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = DEVICE_CLASS_BATTERY @property @@ -78,7 +99,7 @@ class PowerWallChargeSensor(PowerWallEntity, SensorEntity): return f"{self.base_unique_id}_charge" @property - def state(self): + def native_value(self): """Get the current value in percentage.""" return round(self.coordinator.data[POWERWALL_API_CHARGE]) @@ -87,7 +108,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Energy sensor.""" _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = POWER_KILO_WATT + _attr_native_unit_of_measurement = POWER_KILO_WATT _attr_device_class = DEVICE_CLASS_POWER def __init__( @@ -110,7 +131,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Get the current value in kW.""" return ( self.coordinator.data[POWERWALL_API_METERS] @@ -124,9 +145,46 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): meter = self.coordinator.data[POWERWALL_API_METERS].get_meter(self._meter) return { ATTR_FREQUENCY: round(meter.frequency, 1), - ATTR_ENERGY_EXPORTED: meter.get_energy_exported(), - ATTR_ENERGY_IMPORTED: meter.get_energy_imported(), ATTR_INSTANT_AVERAGE_VOLTAGE: round(meter.average_voltage, 1), ATTR_INSTANT_TOTAL_CURRENT: meter.get_instant_total_current(), ATTR_IS_ACTIVE: meter.is_active(), } + + +class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): + """Representation of an Powerwall Direction Energy sensor.""" + + _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_device_class = DEVICE_CLASS_ENERGY + + def __init__( + self, + meter: MeterType, + coordinator, + site_info, + status, + device_type, + powerwalls_serial_numbers, + meter_direction, + ): + """Initialize the sensor.""" + super().__init__( + coordinator, site_info, status, device_type, powerwalls_serial_numbers + ) + self._meter = meter + self._meter_direction = meter_direction + self._attr_name = ( + f"Powerwall {self._meter.value.title()} {self._meter_direction.title()}" + ) + self._attr_unique_id = ( + f"{self.base_unique_id}_{self._meter.value}_{self._meter_direction}" + ) + + @property + def native_value(self): + """Get the current value in kWh.""" + meter = self.coordinator.data[POWERWALL_API_METERS].get_meter(self._meter) + if self._meter_direction == _METER_DIRECTION_EXPORT: + return meter.get_energy_exported() + return meter.get_energy_imported() diff --git a/homeassistant/components/powerwall/translations/hu.json b/homeassistant/components/powerwall/translations/hu.json index 9f12342595a..d5bc30e7d11 100644 --- a/homeassistant/components/powerwall/translations/hu.json +++ b/homeassistant/components/powerwall/translations/hu.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", + "wrong_version": "Az powerwall nem t\u00e1mogatott szoftververzi\u00f3t haszn\u00e1l. K\u00e9rj\u00fck, fontolja meg a probl\u00e9ma friss\u00edt\u00e9s\u00e9t vagy jelent\u00e9s\u00e9t, hogy megoldhat\u00f3 legyen." }, "flow_title": "{ip_address}", "step": { @@ -15,7 +16,9 @@ "data": { "ip_address": "IP c\u00edm", "password": "Jelsz\u00f3" - } + }, + "description": "A jelsz\u00f3 \u00e1ltal\u00e1ban a Biztons\u00e1gi ment\u00e9s k\u00f6zponti egys\u00e9g sorozatsz\u00e1m\u00e1nak utols\u00f3 5 karaktere, \u00e9s megtal\u00e1lhat\u00f3 a Tesla alkalmaz\u00e1sban, vagy a jelsz\u00f3 utols\u00f3 5 karaktere a Biztons\u00e1gi ment\u00e9s k\u00f6zponti egys\u00e9g 2 ajtaj\u00e1ban.", + "title": "Csatlakoz\u00e1s a powerwallhoz" } } } diff --git a/homeassistant/components/progettihwsw/translations/hu.json b/homeassistant/components/progettihwsw/translations/hu.json index 76af6fb124f..fea70ec88ac 100644 --- a/homeassistant/components/progettihwsw/translations/hu.json +++ b/homeassistant/components/progettihwsw/translations/hu.json @@ -33,7 +33,8 @@ "data": { "host": "Hoszt", "port": "Port" - } + }, + "title": "\u00c1ll\u00edtsa be" } } } diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index f158d2506d1..39ac4c28415 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -10,6 +10,8 @@ from homeassistant import core as hacore from homeassistant.components.climate.const import ( ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_ACTIONS, ) from homeassistant.components.http import HomeAssistantView @@ -315,28 +317,43 @@ class PrometheusMetrics: value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_climate(self, state): - temp = state.attributes.get(ATTR_TEMPERATURE) + def _handle_climate_temp(self, state, attr, metric_name, metric_description): + temp = state.attributes.get(attr) if temp: if self._climate_units == TEMP_FAHRENHEIT: temp = fahrenheit_to_celsius(temp) metric = self._metric( - "climate_target_temperature_celsius", + metric_name, self.prometheus_cli.Gauge, - "Target temperature in degrees Celsius", + metric_description, ) metric.labels(**self._labels(state)).set(temp) - current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) - if current_temp: - if self._climate_units == TEMP_FAHRENHEIT: - current_temp = fahrenheit_to_celsius(current_temp) - metric = self._metric( - "climate_current_temperature_celsius", - self.prometheus_cli.Gauge, - "Current temperature in degrees Celsius", - ) - metric.labels(**self._labels(state)).set(current_temp) + def _handle_climate(self, state): + self._handle_climate_temp( + state, + ATTR_TEMPERATURE, + "climate_target_temperature_celsius", + "Target temperature in degrees Celsius", + ) + self._handle_climate_temp( + state, + ATTR_TARGET_TEMP_HIGH, + "climate_target_temperature_high_celsius", + "Target high temperature in degrees Celsius", + ) + self._handle_climate_temp( + state, + ATTR_TARGET_TEMP_LOW, + "climate_target_temperature_low_celsius", + "Target low temperature in degrees Celsius", + ) + self._handle_climate_temp( + state, + ATTR_CURRENT_TEMPERATURE, + "climate_current_temperature_celsius", + "Current temperature in degrees Celsius", + ) current_action = state.attributes.get(ATTR_HVAC_ACTION) if current_action: diff --git a/homeassistant/components/prosegur/translations/cs.json b/homeassistant/components/prosegur/translations/cs.json index 13c0827ff40..e5bc2b914e1 100644 --- a/homeassistant/components/prosegur/translations/cs.json +++ b/homeassistant/components/prosegur/translations/cs.json @@ -18,6 +18,7 @@ }, "user": { "data": { + "country": "Zem\u011b", "password": "Heslo", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" } diff --git a/homeassistant/components/prosegur/translations/es.json b/homeassistant/components/prosegur/translations/es.json new file mode 100644 index 00000000000..fbccb2f6391 --- /dev/null +++ b/homeassistant/components/prosegur/translations/es.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "El sistema 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" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Vuelva a autenticarse con su cuenta Prosegur.", + "password": "Clave", + "username": "Nombre de Usuario" + } + }, + "user": { + "data": { + "country": "Pa\u00eds", + "password": "Clave", + "username": "Nombre de Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/fi.json b/homeassistant/components/prosegur/translations/fi.json new file mode 100644 index 00000000000..11191d88c1b --- /dev/null +++ b/homeassistant/components/prosegur/translations/fi.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "country": "Maa" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/hu.json b/homeassistant/components/prosegur/translations/hu.json new file mode 100644 index 00000000000..143ae78d534 --- /dev/null +++ b/homeassistant/components/prosegur/translations/hu.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Hiteles\u00edtse \u00fajra Prosegur-fi\u00f3kkal.", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + }, + "user": { + "data": { + "country": "Orsz\u00e1g", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/lt.json b/homeassistant/components/prosegur/translations/lt.json new file mode 100644 index 00000000000..9c06bf84e41 --- /dev/null +++ b/homeassistant/components/prosegur/translations/lt.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "country": "Valstyb\u0117", + "password": "Slapta\u017eodis", + "username": "Vartotojo vardas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/no.json b/homeassistant/components/prosegur/translations/no.json index 5732bb920b2..73bacd26c14 100644 --- a/homeassistant/components/prosegur/translations/no.json +++ b/homeassistant/components/prosegur/translations/no.json @@ -1,14 +1,25 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, "step": { "reauth_confirm": { "data": { + "description": "Autentiser p\u00e5 nytt med Prosegur-kontoen.", "password": "Passord", "username": "Brukernavn" } }, "user": { "data": { + "country": "Land", "password": "Passord", "username": "Brukernavn" } diff --git a/homeassistant/components/prosegur/translations/pt.json b/homeassistant/components/prosegur/translations/pt.json new file mode 100644 index 00000000000..d479d880d7f --- /dev/null +++ b/homeassistant/components/prosegur/translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "reauth_successful": "Reautentica\u00e7\u00e3o bem sucedida" + }, + "error": { + "cannot_connect": "Falha na liga\u00e7\u00e3o" + }, + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/zh-Hans.json b/homeassistant/components/prosegur/translations/zh-Hans.json new file mode 100644 index 00000000000..426e1f2919b --- /dev/null +++ b/homeassistant/components/prosegur/translations/zh-Hans.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u6210\u529f", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "\u4f7f\u7528 Prosegur \u5e10\u6237\u91cd\u65b0\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1\u3002", + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + }, + "user": { + "data": { + "country": "\u56fd\u5bb6/\u5730\u533a", + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 1b0d07c69a3..089e028afd1 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -1,4 +1,6 @@ """Support for Proxmox VE.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -18,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -84,7 +87,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the platform.""" hass.data.setdefault(DOMAIN, {}) @@ -132,7 +135,8 @@ async def async_setup(hass: HomeAssistant, config: dict): await hass.async_add_executor_job(build_client) - coordinators = hass.data[DOMAIN][COORDINATORS] = {} + coordinators: dict[str, dict[str, dict[int, DataUpdateCoordinator]]] = {} + hass.data[DOMAIN][COORDINATORS] = coordinators # Create a coordinator for each vm/container for host_config in config[DOMAIN]: diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index 8fda507ace2..3c296b7d164 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -1,4 +1,6 @@ """Proxy camera platform that enables image processing of camera data.""" +from __future__ import annotations + import asyncio from datetime import timedelta import io @@ -219,13 +221,17 @@ class ProxyCamera(Camera): self._last_image = None self._mode = config.get(CONF_MODE) - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return camera image.""" return asyncio.run_coroutine_threadsafe( self.async_camera_image(), self.hass.loop ).result() - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" now = dt_util.utcnow() @@ -244,13 +250,13 @@ class ProxyCamera(Camera): job = _resize_image else: job = _crop_image - image = await self.hass.async_add_executor_job( + image_bytes: bytes = await self.hass.async_add_executor_job( job, image.content, self._image_opts ) if self._cache_images: - self._last_image = image - return image + self._last_image = image_bytes + return image_bytes async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from camera images.""" diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index ff0ac45c139..8f4d1d04dcf 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -1,4 +1,6 @@ """Camera platform that receives images through HTTP POST.""" +from __future__ import annotations + import asyncio from collections import deque from datetime import timedelta @@ -155,7 +157,9 @@ class PushCamera(Camera): self.async_write_ha_state() - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response.""" if self.queue: if self._state == STATE_IDLE: diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 4f8ec6a1700..f7c946a91d9 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,34 +1,72 @@ """Pushbullet platform for sensor component.""" +from __future__ import annotations + import logging import threading from pushbullet import InvalidKeyError, Listener, PushBullet 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_API_KEY, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - "application_name": ["Application name"], - "body": ["Body"], - "notification_id": ["Notification ID"], - "notification_tag": ["Notification tag"], - "package_name": ["Package name"], - "receiver_email": ["Receiver email"], - "sender_email": ["Sender email"], - "source_device_iden": ["Sender device ID"], - "title": ["Title"], - "type": ["Type"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="application_name", + name="Application name", + ), + SensorEntityDescription( + key="body", + name="Body", + ), + SensorEntityDescription( + key="notification_id", + name="Notification ID", + ), + SensorEntityDescription( + key="notification_tag", + name="Notification tag", + ), + SensorEntityDescription( + key="package_name", + name="Package name", + ), + SensorEntityDescription( + key="receiver_email", + name="Receiver email", + ), + SensorEntityDescription( + key="sender_email", + name="Sender email", + ), + SensorEntityDescription( + key="source_device_iden", + name="Sender device ID", + ), + SensorEntityDescription( + key="title", + name="Title", + ), + SensorEntityDescription( + key="type", + name="Type", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=["title", "body"]): vol.All( - cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)] + cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_KEYS)] ), } ) @@ -45,21 +83,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None): pbprovider = PushBulletNotificationProvider(pushbullet) - devices = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - devices.append(PushBulletNotificationSensor(pbprovider, sensor_type)) - add_entities(devices) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + PushBulletNotificationSensor(pbprovider, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + add_entities(entities) class PushBulletNotificationSensor(SensorEntity): """Representation of a Pushbullet Sensor.""" - def __init__(self, pb, element): + def __init__(self, pb, description: SensorEntityDescription): """Initialize the Pushbullet sensor.""" + self.entity_description = description self.pushbullet = pb - self._element = element - self._state = None - self._state_attributes = None + + self._attr_name = f"Pushbullet {description.key}" def update(self): """Fetch the latest data from the sensor. @@ -68,26 +109,11 @@ class PushBulletNotificationSensor(SensorEntity): attributes into self._state_attributes. """ try: - self._state = self.pushbullet.data[self._element] - self._state_attributes = self.pushbullet.data + self._attr_native_value = self.pushbullet.data[self.entity_description.key] + self._attr_extra_state_attributes = self.pushbullet.data except (KeyError, TypeError): pass - @property - def name(self): - """Return the name of the sensor.""" - return f"Pushbullet {self._element}" - - @property - def state(self): - """Return the current state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return all known attributes of the sensor.""" - return self._state_attributes - class PushBulletNotificationProvider: """Provider for an account, leading to one or more sensors.""" diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index eb461061dcc..8126e00d8e5 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -1,4 +1,6 @@ """Support for getting collected information from PVOutput.""" +from __future__ import annotations + from collections import namedtuple from datetime import timedelta import logging @@ -6,7 +8,12 @@ import logging import voluptuous as vol from homeassistant.components.rest.data import RestData -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + DEVICE_CLASS_ENERGY, + PLATFORM_SCHEMA, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( ATTR_DATE, ATTR_TEMPERATURE, @@ -14,6 +21,7 @@ from homeassistant.const import ( ATTR_VOLTAGE, CONF_API_KEY, CONF_NAME, + ENERGY_WATT_HOUR, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -66,10 +74,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class PvoutputSensor(SensorEntity): """Representation of a PVOutput sensor.""" + _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_native_unit_of_measurement = ENERGY_WATT_HOUR + def __init__(self, rest, name): """Initialize a PVOutput sensor.""" self.rest = rest - self._name = name + self._attr_name = name self.pvcoutput = None self.status = namedtuple( "status", @@ -87,12 +99,7 @@ class PvoutputSensor(SensorEntity): ) @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.pvcoutput is not None: return self.pvcoutput.energy_generation @@ -125,6 +132,7 @@ class PvoutputSensor(SensorEntity): def _async_update_from_rest_data(self): """Update state from the rest data.""" try: + # https://pvoutput.org/help/api_specification.html#get-status-service self.pvcoutput = self.status._make(self.rest.data.split(",")) except TypeError: self.pvcoutput = None diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 3e98274c696..e628dfb9813 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -13,6 +13,7 @@ from homeassistant.helpers.entity_registry import ( async_get, async_migrate_entries, ) +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_POWER, @@ -41,7 +42,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the electricity price sensor from configuration.yaml.""" for conf in config.get(DOMAIN, []): hass.async_create_task( diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 75881f93f0a..9cc5603e35b 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -7,7 +7,7 @@ from typing import Any from aiopvpc import PVPCData -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 CONF_NAME, CURRENCY_EURO, ENERGY_KILO_WATT_HOUR from homeassistant.core import HomeAssistant, callback @@ -51,9 +51,10 @@ async def async_setup_entry( class ElecPriceSensor(RestoreEntity, SensorEntity): """Class to hold the prices of electricity as a sensor.""" - unit_of_measurement = UNIT - icon = ICON - should_poll = False + _attr_icon = ICON + _attr_native_unit_of_measurement = UNIT + _attr_should_poll = False + _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, name, unique_id, pvpc_data_handler): """Initialize the sensor object.""" @@ -106,7 +107,7 @@ class ElecPriceSensor(RestoreEntity, SensorEntity): return self._name @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" return self._pvpc_data.state diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json index 0b980bd58e0..1f706862ee1 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json @@ -6,10 +6,13 @@ "step": { "user": { "data": { + "name": "\u00c9rz\u00e9kel\u0151 neve", "power": "Szerz\u0151d\u00e9s szerinti teljes\u00edtm\u00e9ny (kW)", - "power_p3": "Szerz\u0151d\u00f6tt teljes\u00edtm\u00e9ny P3 v\u00f6lgyid\u0151szakra (kW)" + "power_p3": "Szerz\u0151d\u00f6tt teljes\u00edtm\u00e9ny P3 v\u00f6lgyid\u0151szakra (kW)", + "tariff": "Alkalmazand\u00f3 tarifa f\u00f6ldrajzi z\u00f3n\u00e1nk\u00e9nt" }, - "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.\n Pontosabb magyar\u00e1zat\u00e9rt keresse fel az [integr\u00e1ci\u00f3s dokumentumok] oldalt (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "title": "\u00c9rz\u00e9kel\u0151 be\u00e1ll\u00edt\u00e1sa" } } }, diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index c439d5181be..f568b41776f 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -93,12 +93,12 @@ class PyLoadSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 89a7ab4ba04..922f5b71a3c 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -195,6 +195,7 @@ def execute(hass, filename, source, data=None): "sum": sum, "any": any, "all": all, + "enumerate": enumerate, } builtins = safe_builtins.copy() builtins.update(utility_builtins) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 251407099b1..4663b203248 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -1,11 +1,17 @@ """Support for monitoring the qBittorrent API.""" +from __future__ import annotations + import logging from qbittorrent.client import Client, LoginRequired from requests.exceptions import RequestException 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_NAME, CONF_PASSWORD, @@ -25,11 +31,22 @@ SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" DEFAULT_NAME = "qBittorrent" -SENSOR_TYPES = { - SENSOR_TYPE_CURRENT_STATUS: ["Status", None], - SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], - SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TYPE_CURRENT_STATUS, + name="Status", + ), + SensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED, + name="Down Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED, + name="Up Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -56,12 +73,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config.get(CONF_NAME) - dev = [] - for sensor_type in SENSOR_TYPES: - sensor = QBittorrentSensor(sensor_type, client, name, LoginRequired) - dev.append(sensor) + entities = [ + QBittorrentSensor(description, client, name, LoginRequired) + for description in SENSOR_TYPES + ] - add_entities(dev, True) + add_entities(entities, True) def format_speed(speed): @@ -73,45 +90,29 @@ def format_speed(speed): class QBittorrentSensor(SensorEntity): """Representation of an qBittorrent sensor.""" - def __init__(self, sensor_type, qbittorrent_client, client_name, exception): + def __init__( + self, + description: SensorEntityDescription, + qbittorrent_client, + client_name, + exception, + ): """Initialize the qBittorrent sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.client = qbittorrent_client - self.type = sensor_type - self.client_name = client_name - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._available = False self._exception = exception - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return true if device is available.""" - return self._available - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{client_name} {description.name}" + self._attr_available = False def update(self): """Get the latest data from qBittorrent and updates the state.""" try: data = self.client.sync_main_data() - self._available = True + self._attr_available = True except RequestException: _LOGGER.error("Connection lost") - self._available = False + self._attr_available = False return except self._exception: _LOGGER.error("Invalid authentication") @@ -123,17 +124,18 @@ class QBittorrentSensor(SensorEntity): download = data["server_state"]["dl_info_speed"] upload = data["server_state"]["up_info_speed"] - if self.type == SENSOR_TYPE_CURRENT_STATUS: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TYPE_CURRENT_STATUS: if upload > 0 and download > 0: - self._state = "up_down" + self._attr_native_value = "up_down" elif upload > 0 and download == 0: - self._state = "seeding" + self._attr_native_value = "seeding" elif upload == 0 and download > 0: - self._state = "downloading" + self._attr_native_value = "downloading" else: - self._state = STATE_IDLE + self._attr_native_value = STATE_IDLE - elif self.type == SENSOR_TYPE_DOWNLOAD_SPEED: - self._state = format_speed(download) - elif self.type == SENSOR_TYPE_UPLOAD_SPEED: - self._state = format_speed(upload) + elif sensor_type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._attr_native_value = format_speed(download) + elif sensor_type == SENSOR_TYPE_UPLOAD_SPEED: + self._attr_native_value = format_speed(upload) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index c175d89f60e..b02c977d98d 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -91,7 +91,7 @@ _DRIVE_MON_COND = { "mdi:checkbox-marked-circle-outline", None, ], - "drive_temp": ["Temperature", TEMP_CELSIUS, None, None, DEVICE_CLASS_TEMPERATURE], + "drive_temp": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], } _VOLUME_MON_COND = { "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie", None], @@ -243,7 +243,7 @@ class QNAPSensor(SensorEntity): return self.var_icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self.var_units @@ -256,7 +256,7 @@ class QNAPCPUSensor(QNAPSensor): """A QNAP sensor that monitors CPU stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.var_id == "cpu_temp": return self._api.data["system_stats"]["cpu"]["temp_c"] @@ -268,7 +268,7 @@ class QNAPMemorySensor(QNAPSensor): """A QNAP sensor that monitors memory stats.""" @property - def state(self): + 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": @@ -296,7 +296,7 @@ class QNAPNetworkSensor(QNAPSensor): """A QNAP sensor that monitors network stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.var_id == "network_link_status": nic = self._api.data["system_stats"]["nics"][self.monitor_device] @@ -329,7 +329,7 @@ class QNAPSystemSensor(QNAPSensor): """A QNAP sensor that monitors overall system health.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self.var_id == "status": return self._api.data["system_health"] @@ -358,7 +358,7 @@ class QNAPDriveSensor(QNAPSensor): """A QNAP sensor that monitors HDD/SSD drive stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" data = self._api.data["smart_drive_health"][self.monitor_device] @@ -392,7 +392,7 @@ class QNAPVolumeSensor(QNAPSensor): """A QNAP sensor that monitors storage volume stats.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" data = self._api.data["volumes"][self.monitor_device] diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py index 2f4353063d1..cac288eaef0 100644 --- a/homeassistant/components/qvr_pro/camera.py +++ b/homeassistant/components/qvr_pro/camera.py @@ -1,4 +1,5 @@ """Support for QVR Pro streams.""" +from __future__ import annotations import logging @@ -88,7 +89,9 @@ class QVRProCamera(Camera): return attrs - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get image bytes from camera.""" try: return self._client.get_snapshot(self.guid) diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index f6d0ce7ec28..5de7bc0dccf 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -57,7 +57,7 @@ class QSSensor(QSEntity, SensorEntity): self.async_write_ha_state() @property - def state(self): + def native_value(self): """Return the value of the sensor.""" return str(self._val) @@ -67,6 +67,6 @@ class QSSensor(QSEntity, SensorEntity): return f"qs{self.qsid}:{self.channel}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self.unit diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 721fb36fd36..9dbf14e3907 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -54,6 +54,7 @@ SCHEDULE_TYPE_FIXED = "FIXED" SCHEDULE_TYPE_FLEX = "FLEX" SERVICE_PAUSE_WATERING = "pause_watering" SERVICE_RESUME_WATERING = "resume_watering" +SERVICE_STOP_WATERING = "stop_watering" SERVICE_SET_ZONE_MOISTURE = "set_zone_moisture_percent" SERVICE_START_MULTIPLE_ZONES = "start_multiple_zone_schedule" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index ac2fea20bcf..6669a353094 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -26,6 +26,7 @@ from .const import ( MODEL_GENERATION_1, SERVICE_PAUSE_WATERING, SERVICE_RESUME_WATERING, + SERVICE_STOP_WATERING, ) from .webhooks import LISTEN_EVENT_TYPES, WEBHOOK_CONST_ID @@ -44,6 +45,8 @@ PAUSE_SERVICE_SCHEMA = vol.Schema( RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) +STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) + class RachioPerson: """Represent a Rachio user.""" @@ -87,6 +90,13 @@ class RachioPerson: if iro.name in devices: iro.resume_watering() + def stop_water(service): + """Service to stop watering on all or specific controllers.""" + devices = service.data.get(ATTR_DEVICES, all_devices) + for iro in self._controllers: + if iro.name in devices: + iro.stop_watering() + hass.services.async_register( DOMAIN, SERVICE_PAUSE_WATERING, @@ -101,6 +111,13 @@ 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 diff --git a/homeassistant/components/rachio/services.yaml b/homeassistant/components/rachio/services.yaml index bcd853b3ded..67463a22172 100644 --- a/homeassistant/components/rachio/services.yaml +++ b/homeassistant/components/rachio/services.yaml @@ -59,3 +59,13 @@ resume_watering: example: "Main House" selector: text: +stop_watering: + name: Stop watering + description: Stop any currently running zones or schedules. + fields: + devices: + name: Devices + description: Name of controllers to stop. Defaults to all controllers on the account if not provided. + example: "Main House" + selector: + text: diff --git a/homeassistant/components/rachio/translations/hu.json b/homeassistant/components/rachio/translations/hu.json index 570dd27b5d9..0c6112988d8 100644 --- a/homeassistant/components/rachio/translations/hu.json +++ b/homeassistant/components/rachio/translations/hu.json @@ -12,6 +12,17 @@ "user": { "data": { "api_key": "API kulcs" + }, + "description": "Sz\u00fcks\u00e9ge lesz az API-kulcsra a https://app.rach.io/ webhelyen. L\u00e9pjen a Be\u00e1ll\u00edt\u00e1sok elemre, majd kattintson az \u201eAPI KEY GET\u201d lek\u00e9r\u00e9s\u00e9re.", + "title": "Csatlakozzon a Rachio k\u00e9sz\u00fcl\u00e9khez" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "manual_run_mins": "A fut\u00e1s id\u0151tartama percben a z\u00f3nakapcsol\u00f3 aktiv\u00e1l\u00e1sakor" } } } diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index add2580ee87..fc4fff6c274 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -1,12 +1,19 @@ """Support for Radarr.""" +from __future__ import annotations + from datetime import datetime, timedelta import logging import time +from typing import Any import requests 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_API_KEY, CONF_HOST, @@ -42,14 +49,46 @@ DEFAULT_UNIT = DATA_GIGABYTES SCAN_INTERVAL = timedelta(minutes=10) -SENSOR_TYPES = { - "diskspace": ["Disk Space", DATA_GIGABYTES, "mdi:harddisk"], - "upcoming": ["Upcoming", "Movies", "mdi:television"], - "wanted": ["Wanted", "Movies", "mdi:television"], - "movies": ["Movies", "Movies", "mdi:television"], - "commands": ["Commands", "Commands", "mdi:code-braces"], - "status": ["Status", "Status", "mdi:information"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="diskspace", + name="Disk Space", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:harddisk", + ), + SensorEntityDescription( + key="upcoming", + name="Upcoming", + native_unit_of_measurement="Movies", + icon="mdi:television", + ), + SensorEntityDescription( + key="wanted", + name="Wanted", + native_unit_of_measurement="Movies", + icon="mdi:television", + ), + SensorEntityDescription( + key="movies", + name="Movies", + native_unit_of_measurement="Movies", + icon="mdi:television", + ), + SensorEntityDescription( + key="commands", + name="Commands", + native_unit_of_measurement="Commands", + icon="mdi:code-braces", + ), + SensorEntityDescription( + key="status", + name="Status", + native_unit_of_measurement="Status", + icon="mdi:information", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] ENDPOINTS = { "diskspace": "{0}://{1}:{2}/{3}api/diskspace", @@ -78,7 +117,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_INCLUDED, default=[]): cv.ensure_list, vol.Optional(CONF_MONITORED_CONDITIONS, default=["movies"]): vol.All( - cv.ensure_list, [vol.In(list(SENSOR_TYPES))] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, @@ -90,15 +129,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Radarr platform.""" - conditions = config.get(CONF_MONITORED_CONDITIONS) - add_entities([RadarrSensor(hass, config, sensor) for sensor in conditions], True) + conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + RadarrSensor(hass, config, description) + for description in SENSOR_TYPES + if description.key in conditions + ] + add_entities(entities, True) class RadarrSensor(SensorEntity): """Implementation of the Radarr sensor.""" - def __init__(self, hass, conf, sensor_type): + def __init__(self, hass, conf, description: SensorEntityDescription): """Create Radarr entity.""" + self.entity_description = description self.conf = conf self.host = conf.get(CONF_HOST) @@ -110,78 +155,55 @@ class RadarrSensor(SensorEntity): self.included = conf.get(CONF_INCLUDED) self.days = int(conf.get(CONF_DAYS)) self.ssl = "https" if conf.get(CONF_SSL) else "http" - self._state = None - self.data = [] - self.type = sensor_type - self._name = SENSOR_TYPES[self.type][0] - if self.type == "diskspace": - self._unit = conf.get(CONF_UNIT) - else: - self._unit = SENSOR_TYPES[self.type][1] - self._icon = SENSOR_TYPES[self.type][2] - self._available = False - - @property - def name(self): - """Return the name of the sensor.""" - return "{} {}".format("Radarr", self._name) - - @property - def state(self): - """Return sensor state.""" - return self._state - - @property - def available(self): - """Return sensor availability.""" - return self._available - - @property - def unit_of_measurement(self): - """Return the unit of the sensor.""" - return self._unit + self.data: list[Any] = [] + self._attr_name = f"Radarr {description.name}" + if description.key == "diskspace": + self._attr_native_unit_of_measurement = conf.get(CONF_UNIT) + self._attr_available = False @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" attributes = {} - if self.type == "upcoming": + sensor_type = self.entity_description.key + if sensor_type == "upcoming": for movie in self.data: attributes[to_key(movie)] = get_release_date(movie) - elif self.type == "commands": + elif sensor_type == "commands": for command in self.data: attributes[command["name"]] = command["state"] - elif self.type == "diskspace": + elif sensor_type == "diskspace": for data in self.data: - free_space = to_unit(data["freeSpace"], self._unit) - total_space = to_unit(data["totalSpace"], self._unit) + free_space = to_unit(data["freeSpace"], self.native_unit_of_measurement) + total_space = to_unit( + data["totalSpace"], self.native_unit_of_measurement + ) percentage_used = ( 0 if total_space == 0 else free_space / total_space * 100 ) attributes[data["path"]] = "{:.2f}/{:.2f}{} ({:.2f}%)".format( - free_space, total_space, self._unit, percentage_used + free_space, + total_space, + self.native_unit_of_measurement, + percentage_used, ) - elif self.type == "movies": + elif sensor_type == "movies": for movie in self.data: attributes[to_key(movie)] = movie["downloaded"] - elif self.type == "status": + elif sensor_type == "status": attributes = self.data return attributes - @property - def icon(self): - """Return the icon of the sensor.""" - return self._icon - def update(self): """Update the data for the sensor.""" + sensor_type = self.entity_description.key time_zone = dt_util.get_time_zone(self.hass.config.time_zone) start = get_date(time_zone) end = get_date(time_zone, self.days) try: res = requests.get( - ENDPOINTS[self.type].format( + ENDPOINTS[sensor_type].format( self.ssl, self.host, self.port, self.urlbase, start, end ), headers={"X-Api-Key": self.apikey}, @@ -189,15 +211,15 @@ class RadarrSensor(SensorEntity): ) except OSError: _LOGGER.warning("Host %s is not available", self.host) - self._available = False - self._state = None + self._attr_available = False + self._attr_native_value = None return if res.status_code == HTTP_OK: - if self.type in ["upcoming", "movies", "commands"]: + if sensor_type in ("upcoming", "movies", "commands"): self.data = res.json() - self._state = len(self.data) - elif self.type == "diskspace": + self._attr_native_value = len(self.data) + elif sensor_type == "diskspace": # If included paths are not provided, use all data if self.included == []: self.data = res.json() @@ -206,13 +228,16 @@ class RadarrSensor(SensorEntity): self.data = list( filter(lambda x: x["path"] in self.included, res.json()) ) - self._state = "{:.2f}".format( - to_unit(sum(data["freeSpace"] for data in self.data), self._unit) + self._attr_native_value = "{:.2f}".format( + to_unit( + sum(data["freeSpace"] for data in self.data), + self.native_unit_of_measurement, + ) ) - elif self.type == "status": + elif sensor_type == "status": self.data = res.json() - self._state = self.data["version"] - self._available = True + self._attr_native_value = self.data["version"] + self._attr_available = True def get_date(zone, offset=0): diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 2158bc5cf97..0f6ad41b4e3 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -45,6 +45,6 @@ class RainBirdSensor(SensorEntity): """Get the latest data and updates the states.""" _LOGGER.debug("Updating sensor: %s", self.name) if self.entity_description.key == SENSOR_TYPE_RAINSENSOR: - self._attr_state = self._controller.get_rain_sensor_state() + self._attr_native_value = self._controller.get_rain_sensor_state() elif self.entity_description.key == SENSOR_TYPE_RAINDELAY: - self._attr_state = self._controller.get_rain_delay() + self._attr_native_value = self._controller.get_rain_delay() diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index ee8f68734ad..c550e43285b 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -48,12 +48,12 @@ class RainCloudSensor(RainCloudEntity, SensorEntity): """A sensor implementation for raincloud device.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return UNIT_OF_MEASUREMENT_MAP.get(self._sensor_type) diff --git a/homeassistant/components/rainforest_eagle/__init__.py b/homeassistant/components/rainforest_eagle/__init__.py index 9de4d85797f..44a5624267e 100644 --- a/homeassistant/components/rainforest_eagle/__init__.py +++ b/homeassistant/components/rainforest_eagle/__init__.py @@ -1 +1,28 @@ -"""The rainforest_eagle component.""" +"""The Rainforest Eagle integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import data +from .const import DOMAIN + +PLATFORMS = ("sensor",) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rainforest Eagle from a config entry.""" + coordinator = data.EagleDataCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + 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/rainforest_eagle/config_flow.py b/homeassistant/components/rainforest_eagle/config_flow.py new file mode 100644 index 00000000000..be921c31bf7 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/config_flow.py @@ -0,0 +1,80 @@ +"""Config flow for Rainforest Eagle integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.data_entry_flow import FlowResult + +from . import data +from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def create_schema(user_input: dict[str, Any] | None) -> vol.Schema: + """Create user schema with passed in defaults if available.""" + if user_input is None: + user_input = {} + return vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, + vol.Required(CONF_CLOUD_ID, default=user_input.get(CONF_CLOUD_ID)): str, + vol.Required( + CONF_INSTALL_CODE, default=user_input.get(CONF_INSTALL_CODE) + ): str, + } + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rainforest Eagle.""" + + 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=create_schema(user_input) + ) + + await self.async_set_unique_id(user_input[CONF_CLOUD_ID]) + errors = {} + + try: + eagle_type, hardware_address = await data.async_get_type( + self.hass, + user_input[CONF_CLOUD_ID], + user_input[CONF_INSTALL_CODE], + user_input[CONF_HOST], + ) + except data.CannotConnect: + errors["base"] = "cannot_connect" + except data.InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + user_input[CONF_TYPE] = eagle_type + user_input[CONF_HARDWARE_ADDRESS] = hardware_address + return self.async_create_entry( + title=user_input[CONF_CLOUD_ID], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=create_schema(user_input), errors=errors + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle the import step.""" + await self.async_set_unique_id(user_input[CONF_CLOUD_ID]) + self._abort_if_unique_id_configured() + return await self.async_step_user(user_input) diff --git a/homeassistant/components/rainforest_eagle/const.py b/homeassistant/components/rainforest_eagle/const.py new file mode 100644 index 00000000000..bbbe049a85a --- /dev/null +++ b/homeassistant/components/rainforest_eagle/const.py @@ -0,0 +1,9 @@ +"""Constants for the Rainforest Eagle integration.""" + +DOMAIN = "rainforest_eagle" +CONF_CLOUD_ID = "cloud_id" +CONF_INSTALL_CODE = "install_code" +CONF_HARDWARE_ADDRESS = "hardware_address" + +TYPE_EAGLE_100 = "eagle-100" +TYPE_EAGLE_200 = "eagle-200" diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py new file mode 100644 index 00000000000..76ddb2d25d7 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/data.py @@ -0,0 +1,194 @@ +"""Rainforest data.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import aioeagle +import aiohttp +import async_timeout +from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout +from uEagle import Eagle as Eagle100Reader + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) + +_LOGGER = logging.getLogger(__name__) + +UPDATE_100_ERRORS = (ConnectError, HTTPError, Timeout) + + +class RainforestError(HomeAssistantError): + """Base error.""" + + +class CannotConnect(RainforestError): + """Error to indicate a request failed.""" + + +class InvalidAuth(RainforestError): + """Error to indicate bad auth.""" + + +async def async_get_type(hass, cloud_id, install_code, host): + """Try API call 'get_network_info' to see if target device is Eagle-100 or Eagle-200.""" + # For EAGLE-200, fetch the hardware address of the meter too. + hub = aioeagle.EagleHub( + aiohttp_client.async_get_clientsession(hass), cloud_id, install_code, host=host + ) + + try: + with async_timeout.timeout(30): + meters = await hub.get_device_list() + except aioeagle.BadAuth as err: + raise InvalidAuth from err + except aiohttp.ClientError: + # This can happen if it's an eagle-100 + meters = None + + if meters is not None: + if meters: + hardware_address = meters[0].hardware_address + else: + hardware_address = None + + return TYPE_EAGLE_200, hardware_address + + reader = Eagle100Reader(cloud_id, install_code, host) + + try: + response = await hass.async_add_executor_job(reader.get_network_info) + except ValueError as err: + # This could be invalid auth because it doesn't check 401 and tries to read JSON. + raise InvalidAuth from err + except UPDATE_100_ERRORS as error: + _LOGGER.error("Failed to connect during setup: %s", error) + raise CannotConnect from error + + # Branch to test if target is Legacy Model + if ( + "NetworkInfo" in response + and response["NetworkInfo"].get("ModelId") == "Z109-EAGLE" + ): + return TYPE_EAGLE_100, None + + return None, None + + +class EagleDataCoordinator(DataUpdateCoordinator): + """Get the latest data from the Eagle device.""" + + eagle100_reader: Eagle100Reader | None = None + eagle200_meter: aioeagle.ElectricMeter | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + self.entry = entry + if self.type == TYPE_EAGLE_100: + self.model = "EAGLE-100" + update_method = self._async_update_data_100 + else: + self.model = "EAGLE-200" + update_method = self._async_update_data_200 + + super().__init__( + hass, + _LOGGER, + name=entry.data[CONF_CLOUD_ID], + update_interval=timedelta(seconds=30), + update_method=update_method, + ) + + @property + def cloud_id(self): + """Return the cloud ID.""" + return self.entry.data[CONF_CLOUD_ID] + + @property + def type(self): + """Return entry type.""" + return self.entry.data[CONF_TYPE] + + @property + def hardware_address(self): + """Return hardware address of meter.""" + return self.entry.data[CONF_HARDWARE_ADDRESS] + + @property + def is_connected(self): + """Return if the hub is connected to the electric meter.""" + if self.eagle200_meter: + return self.eagle200_meter.is_connected + + return True + + async def _async_update_data_200(self): + """Get the latest data from the Eagle-200 device.""" + eagle200_meter = self.eagle200_meter + + if eagle200_meter is None: + hub = aioeagle.EagleHub( + aiohttp_client.async_get_clientsession(self.hass), + self.cloud_id, + self.entry.data[CONF_INSTALL_CODE], + host=self.entry.data[CONF_HOST], + ) + eagle200_meter = aioeagle.ElectricMeter.create_instance( + hub, self.hardware_address + ) + is_connected = True + else: + is_connected = eagle200_meter.is_connected + + async with async_timeout.timeout(30): + data = await eagle200_meter.get_device_query() + + if self.eagle200_meter is None: + self.eagle200_meter = eagle200_meter + elif is_connected and not eagle200_meter.is_connected: + _LOGGER.warning("Lost connection with electricity meter") + + _LOGGER.debug("API data: %s", data) + return {var["Name"]: var["Value"] for var in data.values()} + + async def _async_update_data_100(self): + """Get the latest data from the Eagle-100 device.""" + try: + data = await self.hass.async_add_executor_job(self._fetch_data_100) + except UPDATE_100_ERRORS as error: + raise UpdateFailed from error + + _LOGGER.debug("API data: %s", data) + return data + + def _fetch_data_100(self): + """Fetch and return the four sensor values in a dict.""" + if self.eagle100_reader is None: + self.eagle100_reader = Eagle100Reader( + self.cloud_id, + self.entry.data[CONF_INSTALL_CODE], + self.entry.data[CONF_HOST], + ) + + out = {} + + resp = self.eagle100_reader.get_instantaneous_demand()["InstantaneousDemand"] + out["zigbee:InstantaneousDemand"] = resp["Demand"] + + resp = self.eagle100_reader.get_current_summation()["CurrentSummation"] + out["zigbee:CurrentSummationDelivered"] = resp["SummationDelivered"] + out["zigbee:CurrentSummationReceived"] = resp["SummationReceived"] + + return out diff --git a/homeassistant/components/rainforest_eagle/manifest.json b/homeassistant/components/rainforest_eagle/manifest.json index fd28e5b0994..10a7dc35ddc 100644 --- a/homeassistant/components/rainforest_eagle/manifest.json +++ b/homeassistant/components/rainforest_eagle/manifest.json @@ -1,8 +1,14 @@ { "domain": "rainforest_eagle", - "name": "Rainforest Eagle-200", + "name": "Rainforest Eagle", "documentation": "https://www.home-assistant.io/integrations/rainforest_eagle", - "requirements": ["eagle200_reader==0.2.4", "uEagle==0.0.2"], + "requirements": ["aioeagle==1.1.0", "uEagle==0.0.2"], "codeowners": ["@gtdiehl", "@jcalbert"], - "iot_class": "local_polling" + "iot_class": "local_polling", + "config_flow": true, + "dhcp": [ + { + "macaddress": "D8D5B9*" + } + ] } diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 53e94d2070e..6f6b496cfca 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -1,76 +1,64 @@ -"""Support for the Rainforest Eagle-200 energy monitor.""" +"""Support for the Rainforest Eagle energy monitor.""" from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime, timedelta import logging +from typing import Any -from eagle200_reader import EagleReader -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -from uEagle import Eagle as LegacyReader import voluptuous as vol from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, + StateType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_HOST, CONF_IP_ADDRESS, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, + POWER_KILO_WATT, ) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle, dt +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -CONF_CLOUD_ID = "cloud_id" -CONF_INSTALL_CODE = "install_code" -POWER_KILO_WATT = "kW" +from .const import CONF_CLOUD_ID, CONF_INSTALL_CODE, DOMAIN +from .data import EagleDataCoordinator _LOGGER = logging.getLogger(__name__) -MIN_SCAN_INTERVAL = timedelta(seconds=30) - - -@dataclass -class SensorType: - """Rainforest sensor type.""" - - name: str - unit_of_measurement: str - device_class: str | None = None - state_class: str | None = None - last_reset: datetime | None = None - - -SENSORS = { - "instantanous_demand": SensorType( +SENSORS = ( + SensorEntityDescription( + key="zigbee:InstantaneousDemand", + # We can drop the "Eagle-200" part of the name in HA 2021.12 name="Eagle-200 Meter Power Demand", - unit_of_measurement=POWER_KILO_WATT, + native_unit_of_measurement=POWER_KILO_WATT, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), - "summation_delivered": SensorType( + SensorEntityDescription( + key="zigbee:CurrentSummationDelivered", name="Eagle-200 Total Meter Energy Delivered", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), - "summation_received": SensorType( + SensorEntityDescription( + key="zigbee:CurrentSummationReceived", name="Eagle-200 Total Meter Energy Received", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - last_reset=dt.utc_from_timestamp(0), + state_class=STATE_CLASS_TOTAL_INCREASING, ), - "summation_total": SensorType( - name="Eagle-200 Net Meter Energy (Delivered minus Received)", - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - ), -} +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -81,105 +69,86 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def hwtest(cloud_id, install_code, ip_address): - """Try API call 'get_network_info' to see if target device is Legacy or Eagle-200.""" - reader = LeagleReader(cloud_id, install_code, ip_address) - response = reader.get_network_info() - - # Branch to test if target is Legacy Model - if ( - "NetworkInfo" in response - and response["NetworkInfo"].get("ModelId", None) == "Z109-EAGLE" - ): - return reader - - # Branch to test if target is Eagle-200 Model - if ( - "Response" in response - and response["Response"].get("Command", None) == "get_network_info" - ): - return EagleReader(ip_address, cloud_id, install_code) - - # Catch-all if hardware ID tests fail - raise ValueError("Couldn't determine device model.") +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +): + """Import config as config entry.""" + _LOGGER.warning( + "Configuration of the rainforest_eagle platform in YAML is deprecated " + "and will be removed in Home Assistant 2021.11; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: config[CONF_IP_ADDRESS], + CONF_CLOUD_ID: config[CONF_CLOUD_ID], + CONF_INSTALL_CODE: config[CONF_INSTALL_CODE], + }, + ) + ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Create the Eagle-200 sensor.""" - ip_address = config[CONF_IP_ADDRESS] - cloud_id = config[CONF_CLOUD_ID] - install_code = config[CONF_INSTALL_CODE] +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [EagleSensor(coordinator, description) for description in SENSORS] - try: - eagle_reader = hwtest(cloud_id, install_code, ip_address) - except (ConnectError, HTTPError, Timeout, ValueError) as error: - _LOGGER.error("Failed to connect during setup: %s", error) - return + if coordinator.data.get("zigbee:Price") not in (None, "invalid"): + entities.append( + EagleSensor( + coordinator, + SensorEntityDescription( + key="zigbee:Price", + name="Meter Price", + native_unit_of_measurement=f"{coordinator.data['zigbee:PriceCurrency']}/{ENERGY_KILO_WATT_HOUR}", + state_class=STATE_CLASS_MEASUREMENT, + ), + ) + ) - eagle_data = EagleData(eagle_reader) - eagle_data.update() - - add_entities(EagleSensor(eagle_data, condition) for condition in SENSORS) + async_add_entities(entities) -class EagleSensor(SensorEntity): - """Implementation of the Rainforest Eagle-200 sensor.""" +class EagleSensor(CoordinatorEntity, SensorEntity): + """Implementation of the Rainforest Eagle sensor.""" - def __init__(self, eagle_data, sensor_type): + coordinator: EagleDataCoordinator + + def __init__(self, coordinator, entity_description): """Initialize the sensor.""" - self.eagle_data = eagle_data - self._type = sensor_type - sensor_info = SENSORS[sensor_type] - self._attr_name = sensor_info.name - self._attr_unit_of_measurement = sensor_info.unit_of_measurement - self._attr_device_class = sensor_info.device_class - self._attr_state_class = sensor_info.state_class - self._attr_last_reset = sensor_info.last_reset + super().__init__(coordinator) + self.entity_description = entity_description - def update(self): - """Get the energy information from the Rainforest Eagle.""" - self.eagle_data.update() - self._attr_state = self.eagle_data.get_state(self._type) + @property + def unique_id(self) -> str | None: + """Return unique ID of entity.""" + return f"{self.coordinator.cloud_id}-${self.coordinator.hardware_address}-{self.entity_description.key}" + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.is_connected -class EagleData: - """Get the latest data from the Eagle-200 device.""" + @property + def native_value(self) -> StateType: + """Return native value of the sensor.""" + return self.coordinator.data.get(self.entity_description.key) - def __init__(self, eagle_reader): - """Initialize the data object.""" - self._eagle_reader = eagle_reader - self.data = {} - - @Throttle(MIN_SCAN_INTERVAL) - def update(self): - """Get the latest data from the Eagle-200 device.""" - try: - self.data = self._eagle_reader.update() - _LOGGER.debug("API data: %s", self.data) - except (ConnectError, HTTPError, Timeout, ValueError) as error: - _LOGGER.error("Unable to connect during update: %s", error) - self.data = {} - - def get_state(self, sensor_type): - """Get the sensor value from the dictionary.""" - state = self.data.get(sensor_type) - _LOGGER.debug("Updating: %s - %s", sensor_type, state) - return state - - -class LeagleReader(LegacyReader, SensorEntity): - """Wraps uEagle to make it behave like eagle_reader, offering update().""" - - def update(self): - """Fetch and return the four sensor values in a dict.""" - out = {} - - resp = self.get_instantaneous_demand()["InstantaneousDemand"] - out["instantanous_demand"] = resp["Demand"] - - resp = self.get_current_summation()["CurrentSummation"] - out["summation_delivered"] = resp["SummationDelivered"] - out["summation_received"] = resp["SummationReceived"] - out["summation_total"] = out["summation_delivered"] - out["summation_received"] - - return out + @property + def device_info(self) -> DeviceInfo | None: + """Return device info.""" + return { + "name": self.coordinator.model, + "identifiers": {(DOMAIN, self.coordinator.cloud_id)}, + "manufacturer": "Rainforest Automation", + "model": self.coordinator.model, + } diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json new file mode 100644 index 00000000000..b32f38302f4 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "cloud_id": "Cloud ID", + "install_code": "Installation Code" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/rainforest_eagle/translations/ca.json b/homeassistant/components/rainforest_eagle/translations/ca.json new file mode 100644 index 00000000000..8d9fb32f127 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "cloud_id": "ID del n\u00favol", + "host": "Amfitri\u00f3", + "install_code": "Codi d'instal\u00b7laci\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/de.json b/homeassistant/components/rainforest_eagle/translations/de.json new file mode 100644 index 00000000000..7fb839ec5c3 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/de.json @@ -0,0 +1,21 @@ +{ + "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": { + "cloud_id": "Cloud-ID", + "host": "Host", + "install_code": "Installations-Code" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/en.json b/homeassistant/components/rainforest_eagle/translations/en.json new file mode 100644 index 00000000000..633d6551bd0 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "host": "Host", + "install_code": "Installation Code" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/et.json b/homeassistant/components/rainforest_eagle/translations/et.json new file mode 100644 index 00000000000..336f7e34e8d --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "cloud_id": "Pilveteenuse ID", + "host": "Host", + "install_code": "Paigalduskood" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/he.json b/homeassistant/components/rainforest_eagle/translations/he.json new file mode 100644 index 00000000000..7a313e6cb4e --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/he.json @@ -0,0 +1,21 @@ +{ + "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": { + "cloud_id": "\u05de\u05d6\u05d4\u05d4 \u05e2\u05e0\u05df", + "host": "\u05de\u05d0\u05e8\u05d7", + "install_code": "\u05e7\u05d5\u05d3 \u05d4\u05ea\u05e7\u05e0\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/nl.json b/homeassistant/components/rainforest_eagle/translations/nl.json new file mode 100644 index 00000000000..e0f5b0ca502 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/nl.json @@ -0,0 +1,21 @@ +{ + "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": { + "cloud_id": "Cloud ID", + "host": "Host", + "install_code": "Installatiecode" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/no.json b/homeassistant/components/rainforest_eagle/translations/no.json new file mode 100644 index 00000000000..35e1a25dcfc --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "host": "Vert", + "install_code": "Installasjonskode" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/pl.json b/homeassistant/components/rainforest_eagle/translations/pl.json new file mode 100644 index 00000000000..8d12ee56c27 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "cloud_id": "Identyfikator chmury", + "host": "Nazwa hosta lub adres IP", + "install_code": "Kod instalacji" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/ru.json b/homeassistant/components/rainforest_eagle/translations/ru.json new file mode 100644 index 00000000000..fa9310eb0a8 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "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": { + "cloud_id": "Cloud ID", + "host": "\u0425\u043e\u0441\u0442", + "install_code": "\u041a\u043e\u0434 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainforest_eagle/translations/zh-Hant.json b/homeassistant/components/rainforest_eagle/translations/zh-Hant.json new file mode 100644 index 00000000000..408792dd291 --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "cloud_id": "Cloud ID", + "host": "\u4e3b\u6a5f\u7aef", + "install_code": "\u5b89\u88dd\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 8d3f9444f08..fac929e7e99 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -180,7 +181,7 @@ class RainMachineEntity(CoordinatorEntity): self, coordinator: DataUpdateCoordinator, controller: Controller, - entity_type: str, + description: EntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) @@ -200,9 +201,9 @@ class RainMachineEntity(CoordinatorEntity): # The colons are removed from the device MAC simply because that value # (unnecessarily) makes up the existing unique ID formula and we want to avoid # a breaking change: - self._attr_unique_id = f"{controller.mac.replace(':', '')}_{entity_type}" + self._attr_unique_id = f"{controller.mac.replace(':', '')}_{description.key}" self._controller = controller - self._entity_type = entity_type + self.entity_description = description @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index b666d9ed150..7e886dbad90 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -1,13 +1,14 @@ """This platform provides binary sensors for key RainMachine data.""" +from dataclasses import dataclass from functools import partial -from regenmaschine.controller import Controller - -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import RainMachineEntity from .const import ( @@ -18,6 +19,7 @@ from .const import ( DATA_RESTRICTIONS_UNIVERSAL, DOMAIN, ) +from .model import RainMachineSensorDescriptionMixin TYPE_FLOW_SENSOR = "flow_sensor" TYPE_FREEZE = "freeze" @@ -29,47 +31,75 @@ TYPE_RAINDELAY = "raindelay" TYPE_RAINSENSOR = "rainsensor" TYPE_WEEKDAY = "weekday" -BINARY_SENSORS = { - TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump", True, DATA_PROVISION_SETTINGS), - TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel", True, DATA_RESTRICTIONS_CURRENT), - TYPE_FREEZE_PROTECTION: ( - "Freeze Protection", - "mdi:weather-snowy", - True, - DATA_RESTRICTIONS_UNIVERSAL, + +@dataclass +class RainMachineBinarySensorDescription( + BinarySensorEntityDescription, RainMachineSensorDescriptionMixin +): + """Describe a RainMachine binary sensor.""" + + +BINARY_SENSOR_DESCRIPTIONS = ( + RainMachineBinarySensorDescription( + key=TYPE_FLOW_SENSOR, + name="Flow Sensor", + icon="mdi:water-pump", + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_HOT_DAYS: ( - "Extra Water on Hot Days", - "mdi:thermometer-lines", - True, - DATA_RESTRICTIONS_UNIVERSAL, + RainMachineBinarySensorDescription( + key=TYPE_FREEZE, + name="Freeze Restrictions", + icon="mdi:cancel", + api_category=DATA_RESTRICTIONS_CURRENT, ), - TYPE_HOURLY: ( - "Hourly Restrictions", - "mdi:cancel", - False, - DATA_RESTRICTIONS_CURRENT, + RainMachineBinarySensorDescription( + key=TYPE_FREEZE_PROTECTION, + name="Freeze Protection", + icon="mdi:weather-snowy", + api_category=DATA_RESTRICTIONS_UNIVERSAL, ), - TYPE_MONTH: ("Month Restrictions", "mdi:cancel", False, DATA_RESTRICTIONS_CURRENT), - TYPE_RAINDELAY: ( - "Rain Delay Restrictions", - "mdi:cancel", - False, - DATA_RESTRICTIONS_CURRENT, + RainMachineBinarySensorDescription( + key=TYPE_HOT_DAYS, + name="Extra Water on Hot Days", + icon="mdi:thermometer-lines", + api_category=DATA_RESTRICTIONS_UNIVERSAL, ), - TYPE_RAINSENSOR: ( - "Rain Sensor Restrictions", - "mdi:cancel", - False, - DATA_RESTRICTIONS_CURRENT, + RainMachineBinarySensorDescription( + key=TYPE_HOURLY, + name="Hourly Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, ), - TYPE_WEEKDAY: ( - "Weekday Restrictions", - "mdi:cancel", - False, - DATA_RESTRICTIONS_CURRENT, + RainMachineBinarySensorDescription( + key=TYPE_MONTH, + name="Month Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, ), -} + RainMachineBinarySensorDescription( + key=TYPE_RAINDELAY, + name="Rain Delay Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, + ), + RainMachineBinarySensorDescription( + key=TYPE_RAINSENSOR, + name="Rain Sensor Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, + ), + RainMachineBinarySensorDescription( + key=TYPE_WEEKDAY, + name="Weekday Restrictions", + icon="mdi:cancel", + entity_registry_enabled_default=False, + api_category=DATA_RESTRICTIONS_CURRENT, + ), +) async def async_setup_entry( @@ -101,74 +131,49 @@ async def async_setup_entry( async_add_entities( [ - async_get_sensor(api_category)( - controller, sensor_type, name, icon, enabled_by_default - ) - for ( - sensor_type, - (name, icon, enabled_by_default, api_category), - ) in BINARY_SENSORS.items() + async_get_sensor(description.api_category)(controller, description) + for description in BINARY_SENSOR_DESCRIPTIONS ] ) -class RainMachineBinarySensor(RainMachineEntity, BinarySensorEntity): - """Define a general RainMachine binary sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - controller: Controller, - sensor_type: str, - name: str, - icon: str, - enabled_by_default: bool, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, controller, sensor_type) - - self._attr_entity_registry_enabled_default = enabled_by_default - self._attr_icon = icon - self._attr_name = name - - -class CurrentRestrictionsBinarySensor(RainMachineBinarySensor): +class CurrentRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity): """Define a binary sensor that handles current restrictions data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FREEZE: + if self.entity_description.key == TYPE_FREEZE: self._attr_is_on = self.coordinator.data["freeze"] - elif self._entity_type == TYPE_HOURLY: + elif self.entity_description.key == TYPE_HOURLY: self._attr_is_on = self.coordinator.data["hourly"] - elif self._entity_type == TYPE_MONTH: + elif self.entity_description.key == TYPE_MONTH: self._attr_is_on = self.coordinator.data["month"] - elif self._entity_type == TYPE_RAINDELAY: + elif self.entity_description.key == TYPE_RAINDELAY: self._attr_is_on = self.coordinator.data["rainDelay"] - elif self._entity_type == TYPE_RAINSENSOR: + elif self.entity_description.key == TYPE_RAINSENSOR: self._attr_is_on = self.coordinator.data["rainSensor"] - elif self._entity_type == TYPE_WEEKDAY: + elif self.entity_description.key == TYPE_WEEKDAY: self._attr_is_on = self.coordinator.data["weekDay"] -class ProvisionSettingsBinarySensor(RainMachineBinarySensor): +class ProvisionSettingsBinarySensor(RainMachineEntity, BinarySensorEntity): """Define a binary sensor that handles provisioning data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FLOW_SENSOR: + if self.entity_description.key == TYPE_FLOW_SENSOR: self._attr_is_on = self.coordinator.data["system"].get("useFlowSensor") -class UniversalRestrictionsBinarySensor(RainMachineBinarySensor): +class UniversalRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity): """Define a binary sensor that handles universal restrictions data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FREEZE_PROTECTION: + if self.entity_description.key == TYPE_FREEZE_PROTECTION: self._attr_is_on = self.coordinator.data["freezeProtectEnabled"] - elif self._entity_type == TYPE_HOT_DAYS: + elif self.entity_description.key == TYPE_HOT_DAYS: self._attr_is_on = self.coordinator.data["hotDaysExtraWatering"] diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py new file mode 100644 index 00000000000..cd66c05025b --- /dev/null +++ b/homeassistant/components/rainmachine/model.py @@ -0,0 +1,9 @@ +"""Define RainMachine data models.""" +from dataclasses import dataclass + + +@dataclass +class RainMachineSensorDescriptionMixin: + """Define an entity description mixin for binary and regular sensors.""" + + api_category: str diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 808c6a06bc2..f990dd5c672 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -1,9 +1,10 @@ """This platform provides support for sensor data from RainMachine.""" +from __future__ import annotations + +from dataclasses import dataclass from functools import partial -from regenmaschine.controller import Controller - -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, @@ -12,7 +13,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import RainMachineEntity from .const import ( @@ -22,6 +22,7 @@ from .const import ( DATA_RESTRICTIONS_UNIVERSAL, DOMAIN, ) +from .model import RainMachineSensorDescriptionMixin TYPE_FLOW_SENSOR_CLICK_M3 = "flow_sensor_clicks_cubic_meter" TYPE_FLOW_SENSOR_CONSUMED_LITERS = "flow_sensor_consumed_liters" @@ -29,48 +30,56 @@ TYPE_FLOW_SENSOR_START_INDEX = "flow_sensor_start_index" TYPE_FLOW_SENSOR_WATERING_CLICKS = "flow_sensor_watering_clicks" TYPE_FREEZE_TEMP = "freeze_protect_temp" -SENSORS = { - TYPE_FLOW_SENSOR_CLICK_M3: ( - "Flow Sensor Clicks", - "mdi:water-pump", - f"clicks/{VOLUME_CUBIC_METERS}", - None, - False, - DATA_PROVISION_SETTINGS, + +@dataclass +class RainMachineSensorEntityDescription( + SensorEntityDescription, RainMachineSensorDescriptionMixin +): + """Describe a RainMachine sensor.""" + + +SENSOR_DESCRIPTIONS = ( + RainMachineSensorEntityDescription( + key=TYPE_FLOW_SENSOR_CLICK_M3, + name="Flow Sensor Clicks", + icon="mdi:water-pump", + native_unit_of_measurement=f"clicks/{VOLUME_CUBIC_METERS}", + entity_registry_enabled_default=False, + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_FLOW_SENSOR_CONSUMED_LITERS: ( - "Flow Sensor Consumed Liters", - "mdi:water-pump", - "liter", - None, - False, - DATA_PROVISION_SETTINGS, + RainMachineSensorEntityDescription( + key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, + name="Flow Sensor Consumed Liters", + icon="mdi:water-pump", + native_unit_of_measurement="liter", + entity_registry_enabled_default=False, + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_FLOW_SENSOR_START_INDEX: ( - "Flow Sensor Start Index", - "mdi:water-pump", - "index", - None, - False, - DATA_PROVISION_SETTINGS, + RainMachineSensorEntityDescription( + key=TYPE_FLOW_SENSOR_START_INDEX, + name="Flow Sensor Start Index", + icon="mdi:water-pump", + native_unit_of_measurement="index", + entity_registry_enabled_default=False, + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_FLOW_SENSOR_WATERING_CLICKS: ( - "Flow Sensor Clicks", - "mdi:water-pump", - "clicks", - None, - False, - DATA_PROVISION_SETTINGS, + RainMachineSensorEntityDescription( + key=TYPE_FLOW_SENSOR_WATERING_CLICKS, + name="Flow Sensor Clicks", + icon="mdi:water-pump", + native_unit_of_measurement="clicks", + entity_registry_enabled_default=False, + api_category=DATA_PROVISION_SETTINGS, ), - TYPE_FREEZE_TEMP: ( - "Freeze Protect Temperature", - "mdi:thermometer", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - True, - DATA_RESTRICTIONS_UNIVERSAL, + RainMachineSensorEntityDescription( + key=TYPE_FREEZE_TEMP, + name="Freeze Protect Temperature", + icon="mdi:thermometer", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + api_category=DATA_RESTRICTIONS_UNIVERSAL, ), -} +) async def async_setup_entry( @@ -96,82 +105,47 @@ async def async_setup_entry( async_add_entities( [ - async_get_sensor(api_category)( - controller, - sensor_type, - name, - icon, - unit, - device_class, - enabled_by_default, - ) - for ( - sensor_type, - (name, icon, unit, device_class, enabled_by_default, api_category), - ) in SENSORS.items() + async_get_sensor(description.api_category)(controller, description) + for description in SENSOR_DESCRIPTIONS ] ) -class RainMachineSensor(RainMachineEntity, SensorEntity): - """Define a general RainMachine sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - controller: Controller, - sensor_type: str, - name: str, - icon: str, - unit: str, - device_class: str, - enabled_by_default: bool, - ) -> None: - """Initialize.""" - super().__init__(coordinator, controller, sensor_type) - - self._attr_device_class = device_class - self._attr_entity_registry_enabled_default = enabled_by_default - self._attr_icon = icon - self._attr_name = name - self._attr_unit_of_measurement = unit - - -class ProvisionSettingsSensor(RainMachineSensor): +class ProvisionSettingsSensor(RainMachineEntity, SensorEntity): """Define a sensor that handles provisioning data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FLOW_SENSOR_CLICK_M3: - self._attr_state = self.coordinator.data["system"].get( + if self.entity_description.key == TYPE_FLOW_SENSOR_CLICK_M3: + self._attr_native_value = self.coordinator.data["system"].get( "flowSensorClicksPerCubicMeter" ) - elif self._entity_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: + elif self.entity_description.key == TYPE_FLOW_SENSOR_CONSUMED_LITERS: clicks = self.coordinator.data["system"].get("flowSensorWateringClicks") clicks_per_m3 = self.coordinator.data["system"].get( "flowSensorClicksPerCubicMeter" ) if clicks and clicks_per_m3: - self._attr_state = (clicks * 1000) / clicks_per_m3 + self._attr_native_value = (clicks * 1000) / clicks_per_m3 else: - self._attr_state = None - elif self._entity_type == TYPE_FLOW_SENSOR_START_INDEX: - self._attr_state = self.coordinator.data["system"].get( + self._attr_native_value = None + elif self.entity_description.key == TYPE_FLOW_SENSOR_START_INDEX: + self._attr_native_value = self.coordinator.data["system"].get( "flowSensorStartIndex" ) - elif self._entity_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: - self._attr_state = self.coordinator.data["system"].get( + elif self.entity_description.key == TYPE_FLOW_SENSOR_WATERING_CLICKS: + self._attr_native_value = self.coordinator.data["system"].get( "flowSensorWateringClicks" ) -class UniversalRestrictionsSensor(RainMachineSensor): +class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity): """Define a sensor that handles universal restrictions data.""" @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._entity_type == TYPE_FREEZE_TEMP: - self._attr_state = self.coordinator.data["freezeProtectTemp"] + if self.entity_description.key == TYPE_FREEZE_TEMP: + self._attr_native_value = self.coordinator.data["freezeProtectTemp"] diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 9554b22d783..a4d4bce2383 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Coroutine +from dataclasses import dataclass from datetime import datetime from typing import Any @@ -9,7 +10,7 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RequestError import voluptuous as vol -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID from homeassistant.core import HomeAssistant, callback @@ -115,6 +116,20 @@ SWITCH_TYPE_PROGRAM = "program" SWITCH_TYPE_ZONE = "zone" +@dataclass +class RainMachineSwitchDescriptionMixin: + """Define an entity description mixin for switches.""" + + uid: int + + +@dataclass +class RainMachineSwitchDescription( + SwitchEntityDescription, RainMachineSwitchDescriptionMixin +): + """Describe a RainMachine switch.""" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -166,18 +181,34 @@ async def async_setup_entry( ] zones_coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][DATA_ZONES] - entities: list[RainMachineProgram | RainMachineZone] = [] - - for uid, program in programs_coordinator.data.items(): - entities.append( - RainMachineProgram( - programs_coordinator, controller, uid, program["name"], entry + entities: list[RainMachineProgram | RainMachineZone] = [ + RainMachineProgram( + programs_coordinator, + controller, + entry, + RainMachineSwitchDescription( + key=f"RainMachineProgram_{uid}", + name=program["name"], + uid=uid, + ), + ) + for uid, program in programs_coordinator.data.items() + ] + entities.extend( + [ + RainMachineZone( + zones_coordinator, + controller, + entry, + RainMachineSwitchDescription( + key=f"RainMachineZone_{uid}", + name=zone["name"], + uid=uid, + ), ) - ) - for uid, zone in zones_coordinator.data.items(): - entities.append( - RainMachineZone(zones_coordinator, controller, uid, zone["name"], entry) - ) + for uid, zone in zones_coordinator.data.items() + ] + ) async_add_entities(entities) @@ -186,35 +217,28 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): """A class to represent a generic RainMachine switch.""" _attr_icon = DEFAULT_ICON + entity_description: RainMachineSwitchDescription def __init__( self, coordinator: DataUpdateCoordinator, controller: Controller, - uid: int, - name: str, entry: ConfigEntry, + description: RainMachineSwitchDescription, ) -> None: """Initialize a generic RainMachine switch.""" - super().__init__(coordinator, controller, type(self).__name__) + super().__init__(coordinator, controller, description) self._attr_is_on = False - self._attr_name = name - self._data = coordinator.data[uid] + self._data = coordinator.data[self.entity_description.uid] self._entry = entry self._is_active = True - self._uid = uid @property def available(self) -> bool: """Return True if entity is available.""" return super().available and self._is_active - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{super().unique_id}_{self._uid}" - async def _async_run_switch_coroutine(self, api_coro: Coroutine) -> None: """Run a coroutine to toggle the switch.""" try: @@ -222,7 +246,7 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): except RequestError as err: LOGGER.error( 'Error while toggling %s "%s": %s', - self._entity_type, + self.entity_description.key, self.unique_id, err, ) @@ -231,7 +255,7 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): if resp["statusCode"] != 0: LOGGER.error( 'Error while toggling %s "%s": %s', - self._entity_type, + self.entity_description.key, self.unique_id, resp["message"], ) @@ -301,7 +325,7 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): @callback def update_from_latest_data(self) -> None: """Update the state.""" - self._data = self.coordinator.data[self._uid] + self._data = self.coordinator.data[self.entity_description.uid] self._is_active = self._data["active"] @@ -313,16 +337,16 @@ class RainMachineProgram(RainMachineSwitch): """Return a list of active zones associated with this program.""" return [z for z in self._data["wateringTimes"] if z["active"]] - async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the program off.""" await self._async_run_switch_coroutine( - self._controller.programs.stop(self._uid) + self._controller.programs.stop(self.entity_description.uid) ) - async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the program on.""" await self._async_run_switch_coroutine( - self._controller.programs.start(self._uid) + self._controller.programs.start(self.entity_description.uid) ) @callback @@ -341,10 +365,14 @@ class RainMachineProgram(RainMachineSwitch): self._attr_extra_state_attributes.update( { - ATTR_ID: self._uid, + ATTR_ID: self.entity_description.uid, ATTR_NEXT_RUN: next_run, - ATTR_SOAK: self.coordinator.data[self._uid].get("soak"), - ATTR_STATUS: RUN_STATUS_MAP[self.coordinator.data[self._uid]["status"]], + ATTR_SOAK: self.coordinator.data[self.entity_description.uid].get( + "soak" + ), + ATTR_STATUS: RUN_STATUS_MAP[ + self.coordinator.data[self.entity_description.uid]["status"] + ], ATTR_ZONES: ", ".join(z["name"] for z in self.zones), } ) @@ -353,15 +381,17 @@ class RainMachineProgram(RainMachineSwitch): class RainMachineZone(RainMachineSwitch): """A RainMachine zone.""" - async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" - await self._async_run_switch_coroutine(self._controller.zones.stop(self._uid)) + await self._async_run_switch_coroutine( + self._controller.zones.stop(self.entity_description.uid) + ) - async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" await self._async_run_switch_coroutine( self._controller.zones.start( - self._uid, + self.entity_description.uid, self._entry.options[CONF_ZONE_RUN_TIME], ) ) diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 6465b828be1..91a34639de1 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -58,7 +58,7 @@ class RandomSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -68,7 +68,7 @@ class RandomSensor(SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 92f94a314ee..5d6b66d8abd 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -59,7 +59,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - await client.async_get_next_pickup_event() + await client.async_get_pickup_events() except RecollectError as err: LOGGER.error("Error during setup of integration: %s", err) return self.async_show_form( diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index 258d74915f7..85cb7100a65 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -3,7 +3,7 @@ "name": "ReCollect Waste", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/recollect_waste", - "requirements": ["aiorecollect==1.0.7"], + "requirements": ["aiorecollect==1.0.8"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index beb7c182351..9c9bc9d6bf4 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -1,6 +1,8 @@ """Support for ReCollect Waste sensors.""" from __future__ import annotations +from datetime import date, datetime, time + from aiorecollect.client import PickupType import voluptuous as vol @@ -74,6 +76,12 @@ async def async_setup_platform( ) +@callback +def async_get_utc_midnight(target_date: date) -> datetime: + """Get UTC midnight for a given date.""" + return as_utc(datetime.combine(target_date, time(0))) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -124,7 +132,9 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_TYPES: async_get_pickup_type_names( self._entry, next_pickup_event.pickup_types ), - ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(), + ATTR_NEXT_PICKUP_DATE: async_get_utc_midnight( + next_pickup_event.date + ).isoformat(), } ) - self._attr_state = as_utc(pickup_event.date).isoformat() + self._attr_native_value = async_get_utc_midnight(pickup_event.date).isoformat() diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index e9c12e5f88a..17215eb9845 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -11,9 +11,10 @@ import threading import time from typing import Any, Callable, NamedTuple -from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select +from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm.session import Session from sqlalchemy.pool import StaticPool import voluptuous as vol @@ -50,7 +51,14 @@ import homeassistant.util.dt as dt_util from . import history, migration, purge, statistics from .const import CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX -from .models import Base, Events, RecorderRuns, States +from .models import ( + Base, + Events, + RecorderRuns, + States, + StatisticsRuns, + process_timestamp, +) from .pool import RecorderPool from .util import ( dburl_to_path, @@ -174,6 +182,17 @@ async def async_migration_in_progress(hass: HomeAssistant) -> bool: return hass.data[DATA_INSTANCE].migration_in_progress +@bind_hass +def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool: + """Check if an entity is being recorded. + + Async friendly. + """ + if DATA_INSTANCE not in hass.data: + return False + return hass.data[DATA_INSTANCE].entity_filter(entity_id) + + def run_information(hass, point_in_time: datetime | None = None): """Return information about current run. @@ -550,12 +569,18 @@ class Recorder(threading.Thread): start = statistics.get_start_time() self.queue.put(StatisticsTask(start)) + @callback def _async_setup_periodic_tasks(self): """Prepare periodic tasks.""" + if self.hass.is_stopping or not self.get_session: + # Home Assistant is shutting down + return + # Run nightly tasks at 4:12am async_track_time_change( self.hass, self.async_nightly_tasks, hour=4, minute=12, second=0 ) + # Compile hourly statistics every hour at *:12 async_track_time_change( self.hass, self.async_hourly_statistics, minute=12, second=0 @@ -595,7 +620,7 @@ class Recorder(threading.Thread): if not schema_is_current: if self._migrate_schema_and_setup_run(current_version): if not self._event_listener: - # If the schema migration takes so longer that the end + # If the schema migration takes so long that the end # queue watcher safety kicks in because MAX_QUEUE_BACKLOG # is reached, we need to reinitialize the listener. self.hass.add_job(self.async_initialize) @@ -954,7 +979,7 @@ class Recorder(threading.Thread): self.get_session = None def _setup_run(self): - """Log the start of the current run.""" + """Log the start of the current run and schedule any needed jobs.""" with session_scope(session=self.get_session()) as session: start = self.recording_start end_incomplete_runs(session, start) @@ -962,9 +987,28 @@ class Recorder(threading.Thread): session.add(self.run_info) session.flush() session.expunge(self.run_info) + self._schedule_compile_missing_statistics(session) self._open_event_session() + 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) + 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)) + + # Add tasks + while start < last_hour: + end = start + timedelta(hours=1) + _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) + self.queue.put(StatisticsTask(start)) + start = start + timedelta(hours=1) + def _end_session(self): """End the recorder session.""" if self.event_session is None: diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 7e4c9d9b9fa..4558e11c076 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -2,7 +2,7 @@ "domain": "recorder", "name": "Recorder", "documentation": "https://www.home-assistant.io/integrations/recorder", - "requirements": ["sqlalchemy==1.4.17"], + "requirements": ["sqlalchemy==1.4.23"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 06391f2864d..4a5c456df28 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,4 +1,5 @@ """Schema migration helpers.""" +from datetime import timedelta import logging import sqlalchemy @@ -11,6 +12,8 @@ from sqlalchemy.exc import ( ) from sqlalchemy.schema import AddConstraint, DropConstraint +import homeassistant.util.dt as dt_util + from .models import ( SCHEMA_VERSION, TABLE_STATES, @@ -18,6 +21,7 @@ from .models import ( SchemaChanges, Statistics, StatisticsMeta, + StatisticsRuns, ) from .util import session_scope @@ -347,7 +351,7 @@ def _drop_foreign_key_constraints(connection, engine, table, columns): ) -def _apply_update(engine, session, new_version, old_version): +def _apply_update(engine, session, new_version, old_version): # noqa: C901 """Perform operations to bring schema up to date.""" connection = session.connection() if new_version == 1: @@ -475,6 +479,28 @@ def _apply_update(engine, session, new_version, old_version): StatisticsMeta.__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)) + elif new_version == 20: + # This changed the precision of statistics from float to double + if engine.dialect.name in ["mysql", "oracle", "postgresql"]: + _modify_columns( + connection, + engine, + "statistics", + [ + "mean DOUBLE PRECISION", + "min DOUBLE PRECISION", + "max DOUBLE PRECISION", + "state DOUBLE PRECISION", + "sum DOUBLE PRECISION", + ], + ) else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -494,6 +520,10 @@ 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(SchemaChanges(schema_version=SCHEMA_VERSION)) return SCHEMA_VERSION diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 929115bdf25..28eff4d9d95 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -19,9 +19,8 @@ from sqlalchemy import ( Text, distinct, ) -from sqlalchemy.dialects import mysql -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.dialects import mysql, oracle, postgresql +from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.const import ( @@ -40,7 +39,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 18 +SCHEMA_VERSION = 20 _LOGGER = logging.getLogger(__name__) @@ -52,6 +51,7 @@ TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" TABLE_STATISTICS_META = "statistics_meta" +TABLE_STATISTICS_RUNS = "statistics_runs" ALL_TABLES = [ TABLE_STATES, @@ -60,11 +60,18 @@ ALL_TABLES = [ TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, ] DATETIME_TYPE = DateTime(timezone=True).with_variant( mysql.DATETIME(timezone=True, fsp=6), "mysql" ) +DOUBLE_TYPE = ( + Float() + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") + .with_variant(oracle.DOUBLE_PRECISION(), "oracle") + .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") +) class Events(Base): # type: ignore @@ -111,7 +118,7 @@ class Events(Base): # type: ignore ) def to_native(self, validate_entity_id=True): - """Convert to a natve HA Event.""" + """Convert to a native HA Event.""" context = Context( id=self.context_id, user_id=self.context_user_id, @@ -240,12 +247,12 @@ class Statistics(Base): # type: ignore index=True, ) start = Column(DATETIME_TYPE, index=True) - mean = Column(Float()) - min = Column(Float()) - max = Column(Float()) + mean = Column(DOUBLE_TYPE) + min = Column(DOUBLE_TYPE) + max = Column(DOUBLE_TYPE) last_reset = Column(DATETIME_TYPE) - state = Column(Float()) - sum = Column(Float()) + state = Column(DOUBLE_TYPE) + sum = Column(DOUBLE_TYPE) @staticmethod def from_stats(metadata_id: str, start: datetime, stats: StatisticData): @@ -260,6 +267,7 @@ class Statistics(Base): # type: ignore class StatisticMetaData(TypedDict, total=False): """Statistic meta data class.""" + statistic_id: str unit_of_measurement: str | None has_mean: bool has_sum: bool @@ -362,6 +370,22 @@ class SchemaChanges(Base): # type: ignore ) +class StatisticsRuns(Base): # type: ignore + """Representation of statistics run.""" + + __tablename__ = TABLE_STATISTICS_RUNS + run_id = Column(Integer, primary_key=True) + start = Column(DateTime(timezone=True)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + def process_timestamp(ts): """Process a timestamp into datetime object.""" if ts is None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index f3b0b27df39..ddc542d23b7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -11,19 +11,26 @@ from sqlalchemy import bindparam from sqlalchemy.ext import baked from sqlalchemy.orm.scoping import scoped_session -from homeassistant.const import PRESSURE_PA, TEMP_CELSIUS +from homeassistant.const import ( + PRESSURE_PA, + TEMP_CELSIUS, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import entity_registry import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util from homeassistant.util.unit_system import UnitSystem +import homeassistant.util.volume as volume_util from .const import DOMAIN from .models import ( StatisticMetaData, Statistics, StatisticsMeta, + StatisticsRuns, process_timestamp_to_utc_isoformat, ) from .util import execute, retryable_database_job, session_scope @@ -46,6 +53,13 @@ QUERY_STATISTIC_META = [ StatisticsMeta.id, StatisticsMeta.statistic_id, StatisticsMeta.unit_of_measurement, + StatisticsMeta.has_mean, + StatisticsMeta.has_sum, +] + +QUERY_STATISTIC_META_ID = [ + StatisticsMeta.id, + StatisticsMeta.statistic_id, ] STATISTICS_BAKERY = "recorder_statistics_bakery" @@ -64,6 +78,11 @@ UNIT_CONVERSIONS = { ) if x is not None else None, + VOLUME_CUBIC_METERS: lambda x, units: volume_util.convert( + x, VOLUME_CUBIC_METERS, _configured_unit(VOLUME_CUBIC_METERS, units) + ) + if x is not None + else None, } _LOGGER = logging.getLogger(__name__) @@ -112,33 +131,61 @@ def _get_metadata_ids( ) -> 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) + 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 [] + return [id for id, _ in result] if result else [] -def _get_or_add_metadata_id( +def _update_or_add_metadata( hass: HomeAssistant, session: scoped_session, statistic_id: str, - metadata: StatisticMetaData, + new_metadata: StatisticMetaData, ) -> str: """Get metadata_id for a statistic_id, add if it doesn't exist.""" - metadata_id = _get_metadata_ids(hass, session, [statistic_id]) - if not metadata_id: - unit = metadata["unit_of_measurement"] - has_mean = metadata["has_mean"] - has_sum = metadata["has_sum"] + old_metadata_dict = _get_metadata(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_id = _get_metadata_ids(hass, session, [statistic_id]) - return metadata_id[0] + metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) + _LOGGER.debug( + "Added new statistics metadata for %s, new_metadata: %s", + statistic_id, + new_metadata, + ) + return metadata_ids[0] + + metadata_id, old_metadata = next(iter(old_metadata_dict.items())) + if ( + old_metadata["has_mean"] != new_metadata["has_mean"] + or old_metadata["has_sum"] != new_metadata["has_sum"] + or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] + ): + session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( + { + StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.has_sum: new_metadata["has_sum"], + StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], + }, + synchronize_session=False, + ) + _LOGGER.debug( + "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", + statistic_id, + old_metadata, + new_metadata, + ) + + return metadata_id @retryable_database_job("statistics") @@ -146,6 +193,12 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: """Compile statistics.""" start = dt_util.as_utc(start) end = start + timedelta(hours=1) + + 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 = [] for domain, platform in instance.hass.data[DOMAIN].items(): @@ -159,10 +212,11 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: 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 = _get_or_add_metadata_id( + metadata_id = _update_or_add_metadata( instance.hass, session, entity_id, stat["meta"] ) session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) + session.add(StatisticsRuns(start=start)) return True @@ -172,14 +226,19 @@ def _get_metadata( session: scoped_session, statistic_ids: list[str] | None, statistic_type: str | None, -) -> dict[str, dict[str, str]]: +) -> dict[str, StatisticMetaData]: """Fetch meta data.""" - def _meta(metas: list, wanted_metadata_id: str) -> dict[str, str] | None: - meta = None - for metadata_id, statistic_id, unit in metas: + def _meta(metas: list, wanted_metadata_id: str) -> StatisticMetaData | None: + meta: StatisticMetaData | None = None + for metadata_id, statistic_id, unit, has_mean, has_sum in metas: if metadata_id == wanted_metadata_id: - meta = {"unit_of_measurement": unit, "statistic_id": statistic_id} + meta = { + "statistic_id": statistic_id, + "unit_of_measurement": unit, + "has_mean": has_mean, + "has_sum": has_sum, + } return meta baked_query = hass.data[STATISTICS_META_BAKERY]( @@ -200,7 +259,7 @@ def _get_metadata( return {} metadata_ids = [metadata[0] for metadata in result] - metadata = {} + metadata: dict[str, StatisticMetaData] = {} for _id in metadata_ids: meta = _meta(result, _id) if meta: @@ -208,18 +267,35 @@ def _get_metadata( return metadata +def get_metadata( + hass: HomeAssistant, + statistic_id: str, +) -> StatisticMetaData | None: + """Return metadata for a statistic_id.""" + statistic_ids = [statistic_id] + 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]) + + def _configured_unit(unit: str, units: UnitSystem) -> str: """Return the pressure and temperature units configured by the user.""" if unit == PRESSURE_PA: return units.pressure_unit if unit == TEMP_CELSIUS: return units.temperature_unit + if unit == VOLUME_CUBIC_METERS: + if units.is_metric: + return VOLUME_CUBIC_METERS + return VOLUME_CUBIC_FEET return unit def list_statistic_ids( hass: HomeAssistant, statistic_type: str | None = None -) -> list[dict[str, str] | None]: +) -> list[StatisticMetaData | None]: """Return statistic_ids and meta data.""" units = hass.config.units statistic_ids = {} @@ -227,7 +303,9 @@ def list_statistic_ids( metadata = _get_metadata(hass, session, None, statistic_type) for meta in metadata.values(): - unit = _configured_unit(meta["unit_of_measurement"], units) + unit = meta["unit_of_measurement"] + if unit is not None: + unit = _configured_unit(unit, units) meta["unit_of_measurement"] = unit statistic_ids = { @@ -241,7 +319,8 @@ def list_statistic_ids( platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type) for statistic_id, unit in platform_statistic_ids.items(): - unit = _configured_unit(unit, units) + if unit is not None: + unit = _configured_unit(unit, units) platform_statistic_ids[statistic_id] = unit statistic_ids = {**statistic_ids, **platform_statistic_ids} @@ -331,7 +410,7 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, stats: list, statistic_ids: list[str] | None, - metadata: dict[str, dict[str, str]], + metadata: dict[str, StatisticMetaData], ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 225eee6867f..f492b754125 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -10,6 +10,7 @@ import os import time from typing import TYPE_CHECKING, Callable +from sqlalchemy import text from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.session import Session @@ -23,6 +24,7 @@ from .models import ( TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, RecorderRuns, process_timestamp, ) @@ -182,7 +184,8 @@ def basic_sanity_check(cursor): """Check tables to make sure select does not fail.""" for table in ALL_TABLES: - if table in [TABLE_STATISTICS, TABLE_STATISTICS_META]: + # The statistics tables may not be present in old databases + if table in [TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS]: continue if table in (TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES): cursor.execute(f"SELECT * FROM {table};") # nosec # not injection @@ -332,4 +335,5 @@ def perodic_db_cleanups(instance: Recorder): if instance.engine.dialect.name == "sqlite": # Execute sqlite to create a wal checkpoint and free up disk space _LOGGER.debug("WAL checkpoint") - instance.engine.execute("PRAGMA wal_checkpoint(TRUNCATE);") + with instance.engine.connect() as connection: + connection.execute(text("PRAGMA wal_checkpoint(TRUNCATE);")) diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 1e755b950bf..2e1ec5dc18a 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -91,7 +91,7 @@ class RedditSensor(SensorEntity): self._limit = limit self._sort_by = sort_by - self._subreddit_data = [] + self._subreddit_data: list = [] @property def name(self): @@ -99,7 +99,7 @@ class RedditSensor(SensorEntity): return f"reddit_{self._subreddit}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return len(self._subreddit_data) diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 78b713c286c..99e2f90c879 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -105,7 +105,7 @@ class RejseplanenTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -131,7 +131,7 @@ class RejseplanenTransportSensor(SensorEntity): return attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/remote/device_action.py b/homeassistant/components/remote/device_action.py index aa34eb33224..a337f3275eb 100644 --- a/homeassistant/components/remote/device_action.py +++ b/homeassistant/components/remote/device_action.py @@ -10,6 +10,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN +# mypy: disallow-any-generics + ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) @@ -25,6 +27,8 @@ async def async_call_action_from_config( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions.""" return await toggle_entity.async_get_actions(hass, device_id, DOMAIN) diff --git a/homeassistant/components/remote/device_condition.py b/homeassistant/components/remote/device_condition.py index ed200fd5579..02e6ea6bd23 100644 --- a/homeassistant/components/remote/device_condition.py +++ b/homeassistant/components/remote/device_condition.py @@ -11,6 +11,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN +# mypy: disallow-any-generics + CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( {vol.Required(CONF_DOMAIN): DOMAIN} ) @@ -33,6 +35,8 @@ async def async_get_conditions( return await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return await toggle_entity.async_get_condition_capabilities(hass, config) diff --git a/homeassistant/components/remote/device_trigger.py b/homeassistant/components/remote/device_trigger.py index d8437604f6d..40182cc0114 100644 --- a/homeassistant/components/remote/device_trigger.py +++ b/homeassistant/components/remote/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for remotes.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,11 +30,15 @@ async def async_attach_trigger( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 80433b2106e..d4c065e52ca 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {}) await renault_hub.async_initialise(config_entry) - hass.data[DOMAIN][config_entry.unique_id] = renault_hub + hass.data[DOMAIN][config_entry.entry_id] = renault_hub hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @@ -40,6 +40,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) if unload_ok: - hass.data[DOMAIN].pop(config_entry.unique_id) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py new file mode 100644 index 00000000000..dd3ccb036e0 --- /dev/null +++ b/homeassistant/components/renault/binary_sensor.py @@ -0,0 +1,58 @@ +"""Support for Renault binary sensors.""" +from __future__ import annotations + +from renault_api.kamereon.enums import ChargeState, PlugState + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + BinarySensorEntity, +) +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 RenaultBatteryDataEntity, RenaultDataEntity +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[RenaultDataEntity] = [] + for vehicle in proxy.vehicles.values(): + if "battery" in vehicle.coordinators: + entities.append(RenaultPluggedInSensor(vehicle, "Plugged In")) + entities.append(RenaultChargingSensor(vehicle, "Charging")) + async_add_entities(entities) + + +class RenaultPluggedInSensor(RenaultBatteryDataEntity, BinarySensorEntity): + """Plugged In binary sensor.""" + + _attr_device_class = DEVICE_CLASS_PLUG + + @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 + + +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 diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 51f6c10c6f1..0987d1829ed 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -7,6 +7,7 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 300 # 5 minutes PLATFORMS = [ + "binary_sensor", "sensor", ] diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 51e356934bb..b7a9b40e2c9 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -1,15 +1,25 @@ """Proxy to handle account communication with Renault servers.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.kamereon.models import KamereonVehiclesLink from renault_api.renault_account import RenaultAccount from renault_api.renault_client import RenaultClient from homeassistant.config_entries import ConfigEntry +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 homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL @@ -23,7 +33,6 @@ class RenaultHub: def __init__(self, hass: HomeAssistant, locale: str) -> None: """Initialise proxy.""" - LOGGER.debug("Creating RenaultHub") self._hass = hass self._client = RenaultClient( websession=async_get_clientsession(self._hass), locale=locale @@ -48,18 +57,49 @@ class RenaultHub: self._account = await self._client.get_api_account(account_id) vehicles = await self._account.get_vehicles() + device_registry = dr.async_get(self._hass) if vehicles.vehicleLinks: - for vehicle_link in vehicles.vehicleLinks: - if vehicle_link.vin and vehicle_link.vehicleDetails: - # Generate vehicle proxy - vehicle = RenaultVehicleProxy( - hass=self._hass, - vehicle=await self._account.get_api_vehicle(vehicle_link.vin), - details=vehicle_link.vehicleDetails, - scan_interval=scan_interval, + await asyncio.gather( + *( + self.async_initialise_vehicle( + vehicle_link, + self._account, + scan_interval, + config_entry, + device_registry, ) - await vehicle.async_initialise() - self._vehicles[vehicle_link.vin] = vehicle + for vehicle_link in vehicles.vehicleLinks + ) + ) + + async def async_initialise_vehicle( + self, + vehicle_link: KamereonVehiclesLink, + renault_account: RenaultAccount, + scan_interval: timedelta, + config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, + ) -> None: + """Set up proxy.""" + assert vehicle_link.vin is not None + assert vehicle_link.vehicleDetails is not None + # Generate vehicle proxy + vehicle = RenaultVehicleProxy( + hass=self._hass, + vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), + details=vehicle_link.vehicleDetails, + scan_interval=scan_interval, + ) + await vehicle.async_initialise() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=vehicle.device_info[ATTR_IDENTIFIERS], + manufacturer=vehicle.device_info[ATTR_MANUFACTURER], + name=vehicle.device_info[ATTR_NAME], + model=vehicle.device_info[ATTR_MODEL], + sw_version=vehicle.device_info[ATTR_SW_VERSION], + ) + self._vehicles[vehicle_link.vin] = vehicle async def get_account_ids(self) -> list[str]: """Get Kamereon account ids.""" diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 09e3de9adab..8d4cfea53ee 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -9,6 +9,13 @@ from typing import cast from renault_api.kamereon import models from renault_api.renault_vehicle import RenaultVehicle +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo @@ -33,11 +40,11 @@ class RenaultVehicleProxy: self._vehicle = vehicle self._details = details self._device_info: DeviceInfo = { - "identifiers": {(DOMAIN, cast(str, details.vin))}, - "manufacturer": (details.get_brand_label() or "").capitalize(), - "model": (details.get_model_label() or "").capitalize(), - "name": details.registrationNumber or "", - "sw_version": details.get_model_code() or "", + ATTR_IDENTIFIERS: {(DOMAIN, cast(str, details.vin))}, + ATTR_MANUFACTURER: (details.get_brand_label() or "").capitalize(), + ATTR_MODEL: (details.get_model_label() or "").capitalize(), + ATTR_NAME: details.registrationNumber or "", + ATTR_SW_VERSION: details.get_model_code() or "", } self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 @@ -108,7 +115,7 @@ class RenaultVehicleProxy: coordinator = self.coordinators[key] if coordinator.not_supported: # Remove endpoint as it is not supported for this vehicle. - LOGGER.error( + LOGGER.warning( "Ignoring endpoint %s as it is not supported for this vehicle: %s", coordinator.name, coordinator.last_exception, @@ -116,7 +123,7 @@ class RenaultVehicleProxy: del self.coordinators[key] elif coordinator.access_denied: # Remove endpoint as it is denied for this vehicle. - LOGGER.error( + LOGGER.warning( "Ignoring endpoint %s as it is denied for this vehicle: %s", coordinator.name, coordinator.last_exception, diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 8403a04d001..7ef11fb2afc 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -1,14 +1,14 @@ """Support for Renault sensors.""" from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, @@ -18,8 +18,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.util import slugify from .const import ( DEVICE_CLASS_CHARGE_MODE, @@ -46,20 +44,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" - proxy: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] - entities = await get_entities(proxy) + proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] + entities = get_entities(proxy) async_add_entities(entities) -async def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: +def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: """Create Renault entities for all vehicles.""" entities = [] for vehicle in proxy.vehicles.values(): - entities.extend(await get_vehicle_entities(vehicle)) + entities.extend(get_vehicle_entities(vehicle)) return entities -async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: +def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: """Create Renault entities for single vehicle.""" entities: list[RenaultDataEntity] = [] if "cockpit" in vehicle.coordinators: @@ -78,6 +76,9 @@ async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultData 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")) @@ -88,50 +89,46 @@ class RenaultBatteryAutonomySensor(RenaultBatteryDataEntity, SensorEntity): """Battery autonomy sensor.""" _attr_icon = "mdi:ev-station" - _attr_unit_of_measurement = LENGTH_KILOMETERS + _attr_native_unit_of_measurement = LENGTH_KILOMETERS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """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 + + @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_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return self.data.batteryLevel if self.data else None - @property - def icon(self) -> str: - """Icon handling.""" - return icon_for_battery_level( - battery_level=self.state, charging=self.is_charging - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of this entity.""" - attrs = super().extra_state_attributes - attrs[ATTR_BATTERY_AVAILABLE_ENERGY] = ( - self.data.batteryAvailableEnergy if self.data else None - ) - return attrs - class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): """Battery Temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return self.data.batteryTemperature if self.data else None @@ -142,7 +139,7 @@ class RenaultChargeModeSensor(RenaultChargeModeDataEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_CHARGE_MODE @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of this entity.""" return self.data.chargeMode if self.data else None @@ -160,10 +157,10 @@ class RenaultChargeStateSensor(RenaultBatteryDataEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_CHARGE_STATE @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of this entity.""" charging_status = self.data.get_charging_status() if self.data else None - return slugify(charging_status.name) if charging_status is not None else None + return charging_status.name.lower() if charging_status is not None else None @property def icon(self) -> str: @@ -175,10 +172,10 @@ class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity) """Charging Remaining Time sensor.""" _attr_icon = "mdi:timer" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" return self.data.chargingRemainingTime if self.data else None @@ -186,11 +183,11 @@ class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity) class RenaultChargingPowerSensor(RenaultBatteryDataEntity, SensorEntity): """Charging Power sensor.""" - _attr_device_class = DEVICE_CLASS_ENERGY - _attr_unit_of_measurement = POWER_KILO_WATT + _attr_device_class = DEVICE_CLASS_POWER + _attr_native_unit_of_measurement = POWER_KILO_WATT @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of this entity.""" if not self.data or self.data.chargingInstantaneousPower is None: return None @@ -204,58 +201,52 @@ class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): """Fuel autonomy sensor.""" _attr_icon = "mdi:gas-station" - _attr_unit_of_measurement = LENGTH_KILOMETERS + _attr_native_unit_of_measurement = LENGTH_KILOMETERS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.fuelAutonomy) - if self.data and self.data.fuelAutonomy is not None - else None - ) + if not self.data or self.data.fuelAutonomy is None: + return None + return round(self.data.fuelAutonomy) class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): """Fuel quantity sensor.""" _attr_icon = "mdi:fuel" - _attr_unit_of_measurement = VOLUME_LITERS + _attr_native_unit_of_measurement = VOLUME_LITERS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.fuelQuantity) - if self.data and self.data.fuelQuantity is not None - else None - ) + if not self.data or self.data.fuelQuantity is None: + return None + return round(self.data.fuelQuantity) class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): """Mileage sensor.""" _attr_icon = "mdi:sign-direction" - _attr_unit_of_measurement = LENGTH_KILOMETERS + _attr_native_unit_of_measurement = LENGTH_KILOMETERS @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of this entity.""" - return ( - round(self.data.totalMileage) - if self.data and self.data.totalMileage is not None - else None - ) + if not self.data or self.data.totalMileage is None: + return None + return round(self.data.totalMileage) class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): """HVAC Outside Temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of this entity.""" return self.data.externalTemperature if self.data else None @@ -266,10 +257,10 @@ class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_PLUG_STATE @property - def state(self) -> str | None: + 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 slugify(plug_status.name) if plug_status is not None else None + return plug_status.name.lower() if plug_status is not None else None @property def icon(self) -> str: diff --git a/homeassistant/components/renault/translations/es-419.json b/homeassistant/components/renault/translations/es-419.json new file mode 100644 index 00000000000..6c895416ef8 --- /dev/null +++ b/homeassistant/components/renault/translations/es-419.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Establecer las credenciales de Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/es.json b/homeassistant/components/renault/translations/es.json new file mode 100644 index 00000000000..0eabcacccd3 --- /dev/null +++ b/homeassistant/components/renault/translations/es.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "kamereon_no_account": "No se pudo encontrar la cuenta de Kamereon." + }, + "error": { + "invalid_credentials": "Autenticaci\u00f3n err\u00f3nea" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "ID de cuenta de Kamereon" + }, + "title": "Seleccione el id de la cuenta de Kamereon" + }, + "user": { + "data": { + "locale": "Configuraci\u00f3n regional", + "password": "Clave", + "username": "Correo-e" + }, + "title": "Establecer las credenciales de Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/he.json b/homeassistant/components/renault/translations/he.json index d20e2d36a81..25cec1032e9 100644 --- a/homeassistant/components/renault/translations/he.json +++ b/homeassistant/components/renault/translations/he.json @@ -11,7 +11,8 @@ "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05d3\u05d5\u05d0\"\u05dc" - } + }, + "title": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05e8\u05e0\u05d5" } } } diff --git a/homeassistant/components/renault/translations/hu.json b/homeassistant/components/renault/translations/hu.json new file mode 100644 index 00000000000..eeace0b9b85 --- /dev/null +++ b/homeassistant/components/renault/translations/hu.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "kamereon_no_account": "Nem tal\u00e1lhat\u00f3 a Kamereon-fi\u00f3k." + }, + "error": { + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon-fi\u00f3k azonos\u00edt\u00f3ja" + }, + "title": "V\u00e1lassza ki a Kamereon-fi\u00f3k azonos\u00edt\u00f3j\u00e1t" + }, + "user": { + "data": { + "locale": "Helysz\u00edn", + "password": "Jelsz\u00f3", + "username": "Email" + }, + "title": "\u00c1ll\u00edtsa be a Renault hiteles\u00edt\u0151 adatait" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/lt.json b/homeassistant/components/renault/translations/lt.json new file mode 100644 index 00000000000..883b5c03e2c --- /dev/null +++ b/homeassistant/components/renault/translations/lt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Slapta\u017eodis", + "username": "El. pa\u0161tas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/no.json b/homeassistant/components/renault/translations/no.json index f367c8c540d..4675f939fdd 100644 --- a/homeassistant/components/renault/translations/no.json +++ b/homeassistant/components/renault/translations/no.json @@ -1,11 +1,26 @@ { "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "kamereon_no_account": "Kan ikke finne Kamereon -kontoen." + }, + "error": { + "invalid_credentials": "Ugyldig godkjenning" + }, "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon -konto -ID" + }, + "title": "Velg Kamereon -konto -ID" + }, "user": { "data": { + "locale": "Lokal", "password": "Passord", "username": "E-Post" - } + }, + "title": "Angi Renault-legitimasjon" } } } diff --git a/homeassistant/components/renault/translations/zh-Hans.json b/homeassistant/components/renault/translations/zh-Hans.json new file mode 100644 index 00000000000..ab8c60ed030 --- /dev/null +++ b/homeassistant/components/renault/translations/zh-Hans.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e", + "kamereon_no_account": "\u65e0\u6cd5\u627e\u5230 Kamereon \u5e10\u6237" + }, + "error": { + "invalid_credentials": "\u65e0\u6548\u8ba4\u8bc1" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon \u8d26\u53f7 ID" + }, + "title": "\u9009\u62e9 Kamereon \u8d26\u53f7 ID" + }, + "user": { + "data": { + "locale": "\u5730\u533a", + "password": "\u5bc6\u7801", + "username": "\u7535\u5b50\u90ae\u7bb1" + }, + "title": "\u8bbe\u7f6e Renault \u51ed\u8bc1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 46818095647..04cff82bcf3 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -77,7 +77,7 @@ class RepetierSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return SENSOR_TYPES[self._sensor_type][1] @@ -92,7 +92,7 @@ class RepetierSensor(SensorEntity): return False @property - def state(self): + def native_value(self): """Return sensor state.""" return self._state @@ -134,7 +134,7 @@ class RepetierTempSensor(RepetierSensor): """Represent a Repetier temp sensor.""" @property - def state(self): + def native_value(self): """Return sensor state.""" if self._state is None: return None @@ -156,7 +156,7 @@ class RepetierJobSensor(RepetierSensor): """Represent a Repetier job sensor.""" @property - def state(self): + def native_value(self): """Return sensor state.""" if self._state is None: return None diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 8b9390bb1c9..8186db1c3c2 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -31,6 +31,7 @@ from homeassistant.helpers.entity_component import ( EntityComponent, ) from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_DATA, REST_IDX @@ -43,7 +44,7 @@ PLATFORMS = ["binary_sensor", "notify", "sensor", "switch"] COORDINATOR_AWARE_PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the rest platforms.""" component = EntityComponent(_LOGGER, DOMAIN, hass) _async_setup_shared_data(hass) @@ -67,7 +68,7 @@ async def async_setup(hass: HomeAssistant, config: dict): @callback def _async_setup_shared_data(hass: HomeAssistant): """Create shared data for platform config and rest coordinators.""" - hass.data[DOMAIN] = {key: [] for key in [REST_DATA, *COORDINATOR_AWARE_PLATFORMS]} + hass.data[DOMAIN] = {key: [] for key in (REST_DATA, *COORDINATOR_AWARE_PLATFORMS)} async def _async_process_config(hass, config) -> bool: diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 7727b5f09ab..f0355014986 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -115,12 +115,12 @@ class RestSensor(RestEntity, SensorEntity): self._json_attrs_path = json_attrs_path @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 497c9b8cee6..6b0c9efe157 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -152,12 +152,12 @@ class RflinkSensor(RflinkDevice, SensorEntity): self.handle_event_callback(self._initial_event) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return measurement unit.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return value.""" return self._state diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 44e1d537408..34b7c01600a 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -1,7 +1,6 @@ """Support for RFXtrx devices.""" import asyncio import binascii -from collections import OrderedDict import copy import functools import logging @@ -22,20 +21,7 @@ from homeassistant.const import ( CONF_DEVICES, CONF_HOST, CONF_PORT, - DEGREE, - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_STOP, - LENGTH_MILLIMETERS, - PERCENTAGE, - POWER_WATT, - PRECIPITATION_MILLIMETERS_PER_HOUR, - PRESSURE_HPA, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - SPEED_METERS_PER_SECOND, - TEMP_CELSIUS, - UV_INDEX, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -66,38 +52,6 @@ DEFAULT_SIGNAL_REPETITIONS = 1 SIGNAL_EVENT = f"{DOMAIN}_event" -DATA_TYPES = OrderedDict( - [ - ("Temperature", TEMP_CELSIUS), - ("Temperature2", TEMP_CELSIUS), - ("Humidity", PERCENTAGE), - ("Barometer", PRESSURE_HPA), - ("Wind direction", DEGREE), - ("Rain rate", PRECIPITATION_MILLIMETERS_PER_HOUR), - ("Energy usage", POWER_WATT), - ("Total usage", ENERGY_KILO_WATT_HOUR), - ("Sound", None), - ("Sensor Status", None), - ("Counter value", "count"), - ("UV", UV_INDEX), - ("Humidity status", None), - ("Forecast", None), - ("Forecast numeric", None), - ("Rain total", LENGTH_MILLIMETERS), - ("Wind average speed", SPEED_METERS_PER_SECOND), - ("Wind gust", SPEED_METERS_PER_SECOND), - ("Chill", TEMP_CELSIUS), - ("Count", "count"), - ("Current Ch. 1", ELECTRIC_CURRENT_AMPERE), - ("Current Ch. 2", ELECTRIC_CURRENT_AMPERE), - ("Current Ch. 3", ELECTRIC_CURRENT_AMPERE), - ("Voltage", ELECTRIC_POTENTIAL_VOLT), - ("Current", ELECTRIC_CURRENT_AMPERE), - ("Battery numeric", PERCENTAGE), - ("Rssi numeric", SIGNAL_STRENGTH_DECIBELS_MILLIWATT), - ] -) - _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 9e3d24cdb6a..f6751d760b2 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -1,4 +1,7 @@ """Support for RFXtrx binary sensors.""" +from __future__ import annotations + +from dataclasses import replace import logging import RFXtrx as rfxtrxmod @@ -7,6 +10,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_SMOKE, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import ( CONF_COMMAND_OFF, @@ -51,13 +55,30 @@ SENSOR_STATUS_OFF = [ "Normal Tamper", ] -DEVICE_TYPE_DEVICE_CLASS = { - "X10 Security Motion Detector": DEVICE_CLASS_MOTION, - "KD101 Smoke Detector": DEVICE_CLASS_SMOKE, - "Visonic Powercode Motion Detector": DEVICE_CLASS_MOTION, - "Alecto SA30 Smoke Detector": DEVICE_CLASS_SMOKE, - "RM174RF Smoke Detector": DEVICE_CLASS_SMOKE, -} +SENSOR_TYPES = ( + BinarySensorEntityDescription( + key="X10 Security Motion Detector", + device_class=DEVICE_CLASS_MOTION, + ), + BinarySensorEntityDescription( + key="KD101 Smoke Detector", + device_class=DEVICE_CLASS_SMOKE, + ), + BinarySensorEntityDescription( + key="Visonic Powercode Motion Detector", + device_class=DEVICE_CLASS_MOTION, + ), + BinarySensorEntityDescription( + key="Alecto SA30 Smoke Detector", + device_class=DEVICE_CLASS_SMOKE, + ), + BinarySensorEntityDescription( + key="RM174RF Smoke Detector", + device_class=DEVICE_CLASS_SMOKE, + ), +) + +SENSOR_TYPES_DICT = {desc.key: desc for desc in SENSOR_TYPES} def supported(event): @@ -85,6 +106,14 @@ async def async_setup_entry( discovery_info = config_entry.data + def get_sensor_description(type_string: str, device_class: str | None = None): + 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(): event = get_rfx_object(packet_id) if event is None: @@ -107,9 +136,8 @@ async def async_setup_entry( device = RfxtrxBinarySensor( event.device, device_id, - entity_info.get( - CONF_DEVICE_CLASS, - DEVICE_TYPE_DEVICE_CLASS.get(event.device.type_string), + get_sensor_description( + event.device.type_string, entity_info.get(CONF_DEVICE_CLASS) ), entity_info.get(CONF_OFF_DELAY), entity_info.get(CONF_DATA_BITS), @@ -137,11 +165,12 @@ async def async_setup_entry( event.device.subtype, "".join(f"{x:02x}" for x in event.data), ) + sensor = RfxtrxBinarySensor( event.device, device_id, event=event, - device_class=DEVICE_TYPE_DEVICE_CLASS.get(event.device.type_string), + entity_description=get_sensor_description(event.device.type_string), ) async_add_entities([sensor]) @@ -156,7 +185,7 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self, device, device_id, - device_class=None, + entity_description, off_delay=None, data_bits=None, cmd_on=None, @@ -165,7 +194,7 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): ): """Initialize the RFXtrx sensor.""" super().__init__(device, device_id, event=event) - self._device_class = device_class + self.entity_description = entity_description self._data_bits = data_bits self._off_delay = off_delay self._state = None @@ -190,11 +219,6 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): """We should force updates. Repeated states have meaning.""" return True - @property - def device_class(self): - """Return the sensor class.""" - return self._device_class - @property def is_on(self): """Return true if the sensor state is True.""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 72cd9f6bbf6..7ce986d7082 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -1,5 +1,9 @@ """Support for RFXtrx sensors.""" +from __future__ import annotations + +from dataclasses import dataclass import logging +from typing import Callable from RFXtrx import ControlEvent, SensorEvent @@ -8,21 +12,36 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( CONF_DEVICES, + DEGREE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + LENGTH_MILLIMETERS, + PERCENTAGE, + POWER_WATT, + PRECIPITATION_MILLIMETERS_PER_HOUR, + PRESSURE_HPA, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, + UV_INDEX, ) from homeassistant.core import callback from . import ( CONF_DATA_BITS, - DATA_TYPES, RfxtrxEntity, connect_auto_add, get_device_id, @@ -47,25 +66,158 @@ def _rssi_convert(value): return f"{value*8-120}" -DEVICE_CLASSES = { - "Barometer": DEVICE_CLASS_PRESSURE, - "Battery numeric": DEVICE_CLASS_BATTERY, - "Current Ch. 1": DEVICE_CLASS_CURRENT, - "Current Ch. 2": DEVICE_CLASS_CURRENT, - "Current Ch. 3": DEVICE_CLASS_CURRENT, - "Energy usage": DEVICE_CLASS_POWER, - "Humidity": DEVICE_CLASS_HUMIDITY, - "Rssi numeric": DEVICE_CLASS_SIGNAL_STRENGTH, - "Temperature": DEVICE_CLASS_TEMPERATURE, - "Total usage": DEVICE_CLASS_ENERGY, - "Voltage": DEVICE_CLASS_VOLTAGE, -} +@dataclass +class RfxtrxSensorEntityDescription(SensorEntityDescription): + """Description of sensor entities.""" + + convert: Callable = lambda x: x -CONVERT_FUNCTIONS = { - "Battery numeric": _battery_convert, - "Rssi numeric": _rssi_convert, -} +SENSOR_TYPES = ( + RfxtrxSensorEntityDescription( + key="Barameter", + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PRESSURE_HPA, + ), + RfxtrxSensorEntityDescription( + key="Battery numeric", + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + convert=_battery_convert, + ), + RfxtrxSensorEntityDescription( + key="Current", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + RfxtrxSensorEntityDescription( + key="Current Ch. 1", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + RfxtrxSensorEntityDescription( + key="Current Ch. 2", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + RfxtrxSensorEntityDescription( + key="Current Ch. 3", + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + ), + RfxtrxSensorEntityDescription( + key="Energy usage", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + RfxtrxSensorEntityDescription( + key="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + RfxtrxSensorEntityDescription( + key="Rssi numeric", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + convert=_rssi_convert, + ), + RfxtrxSensorEntityDescription( + key="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + RfxtrxSensorEntityDescription( + key="Temperature2", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + RfxtrxSensorEntityDescription( + key="Total usage", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + RfxtrxSensorEntityDescription( + key="Voltage", + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + ), + RfxtrxSensorEntityDescription( + key="Wind direction", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=DEGREE, + ), + RfxtrxSensorEntityDescription( + key="Rain rate", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + ), + RfxtrxSensorEntityDescription( + key="Sound", + ), + RfxtrxSensorEntityDescription( + key="Sensor Status", + ), + RfxtrxSensorEntityDescription( + key="Count", + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement="count", + ), + RfxtrxSensorEntityDescription( + key="Counter value", + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement="count", + ), + RfxtrxSensorEntityDescription( + key="Chill", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + RfxtrxSensorEntityDescription( + key="Wind average speed", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + ), + RfxtrxSensorEntityDescription( + key="Wind gust", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + ), + RfxtrxSensorEntityDescription( + key="Rain total", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=LENGTH_MILLIMETERS, + ), + RfxtrxSensorEntityDescription( + key="Forecast", + ), + RfxtrxSensorEntityDescription( + key="Forecast numeric", + ), + RfxtrxSensorEntityDescription( + key="Humidity status", + ), + RfxtrxSensorEntityDescription( + key="UV", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=UV_INDEX, + ), +) + +SENSOR_TYPES_DICT = {desc.key: desc for desc in SENSOR_TYPES} async def async_setup_entry( @@ -92,13 +244,13 @@ async def async_setup_entry( device_id = get_device_id( event.device, data_bits=entity_info.get(CONF_DATA_BITS) ) - for data_type in set(event.values) & set(DATA_TYPES): + for data_type in set(event.values) & set(SENSOR_TYPES_DICT): data_id = (*device_id, data_type) if data_id in data_ids: continue data_ids.add(data_id) - entity = RfxtrxSensor(event.device, device_id, data_type) + entity = RfxtrxSensor(event.device, device_id, SENSOR_TYPES_DICT[data_type]) entities.append(entity) async_add_entities(entities) @@ -109,7 +261,7 @@ async def async_setup_entry( if not supported(event): return - for data_type in set(event.values) & set(DATA_TYPES): + for data_type in set(event.values) & set(SENSOR_TYPES_DICT): data_id = (*device_id, data_type) if data_id in data_ids: continue @@ -123,7 +275,9 @@ async def async_setup_entry( "".join(f"{x:02x}" for x in event.data), ) - entity = RfxtrxSensor(event.device, device_id, data_type, event=event) + entity = RfxtrxSensor( + event.device, device_id, SENSOR_TYPES_DICT[data_type], event=event + ) async_add_entities([entity]) # Subscribe to main RFXtrx events @@ -133,16 +287,16 @@ async def async_setup_entry( class RfxtrxSensor(RfxtrxEntity, SensorEntity): """Representation of a RFXtrx sensor.""" - def __init__(self, device, device_id, data_type, event=None): + entity_description: RfxtrxSensorEntityDescription + + def __init__(self, device, device_id, entity_description, event=None): """Initialize the sensor.""" super().__init__(device, device_id, event=event) - self.data_type = data_type - self._unit_of_measurement = DATA_TYPES.get(data_type) - self._name = f"{device.type_string} {device.id_string} {data_type}" - self._unique_id = "_".join(x for x in (*self._device_id, data_type)) - - self._device_class = DEVICE_CLASSES.get(data_type) - self._convert_fun = CONVERT_FUNCTIONS.get(data_type, lambda x: x) + self.entity_description = entity_description + self._name = f"{device.type_string} {device.id_string} {entity_description.key}" + self._unique_id = "_".join( + x for x in (*self._device_id, entity_description.key) + ) async def async_added_to_hass(self): """Restore device state.""" @@ -156,17 +310,12 @@ class RfxtrxSensor(RfxtrxEntity, SensorEntity): self._apply_event(get_rfx_object(event)) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if not self._event: return None - value = self._event.values.get(self.data_type) - return self._convert_fun(value) - - @property - def unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement + value = self._event.values.get(self.entity_description.key) + return self.entity_description.convert(value) @property def should_poll(self): @@ -178,18 +327,13 @@ class RfxtrxSensor(RfxtrxEntity, SensorEntity): """We should force updates. Repeated states have meaning.""" return True - @property - def device_class(self): - """Return a device class for sensor.""" - return self._device_class - @callback def _handle_event(self, event, device_id): """Check if event applies to me and update.""" if device_id != self._device_id: return - if self.data_type not in event.values: + if self.entity_description.key not in event.values: return _LOGGER.debug( diff --git a/homeassistant/components/rfxtrx/translations/hu.json b/homeassistant/components/rfxtrx/translations/hu.json index d8a27a3173b..5b953c1260e 100644 --- a/homeassistant/components/rfxtrx/translations/hu.json +++ b/homeassistant/components/rfxtrx/translations/hu.json @@ -69,7 +69,8 @@ "off_delay": "Kikapcsol\u00e1si k\u00e9sleltet\u00e9s", "off_delay_enabled": "Kikapcsol\u00e1si k\u00e9sleltet\u00e9s enged\u00e9lyez\u00e9se", "replace_device": "V\u00e1lassza ki a cser\u00e9lni k\u00edv\u00e1nt eszk\u00f6zt", - "signal_repetitions": "A jelism\u00e9tl\u00e9sek sz\u00e1ma" + "signal_repetitions": "A jelism\u00e9tl\u00e9sek sz\u00e1ma", + "venetian_blind_mode": "Velencei red\u0151ny \u00fczemm\u00f3d" }, "title": "Konfigur\u00e1lja az eszk\u00f6z be\u00e1ll\u00edt\u00e1sait" } diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index d2c412a691d..de854022301 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -1,25 +1,49 @@ """This component provides HA sensor support for Ring Door Bell/Chimes.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import datetime from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import callback from . import DOMAIN from .entity import RingEntityMixin -# Sensor types: Name, category, device_class -SENSOR_TYPES = { - "ding": ["Ding", ["doorbots", "authorized_doorbots"], DEVICE_CLASS_OCCUPANCY], - "motion": [ - "Motion", - ["doorbots", "authorized_doorbots", "stickup_cams"], - DEVICE_CLASS_MOTION, - ], -} + +@dataclass +class RingRequiredKeysMixin: + """Mixin for required keys.""" + + category: list[str] + + +@dataclass +class RingBinarySensorEntityDescription( + BinarySensorEntityDescription, RingRequiredKeysMixin +): + """Describes Ring binary sensor entity.""" + + +BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( + RingBinarySensorEntityDescription( + key="ding", + name="Ding", + category=["doorbots", "authorized_doorbots"], + device_class=DEVICE_CLASS_OCCUPANCY, + ), + RingBinarySensorEntityDescription( + key="motion", + name="Motion", + category=["doorbots", "authorized_doorbots", "stickup_cams"], + device_class=DEVICE_CLASS_MOTION, + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -27,35 +51,36 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ring = hass.data[DOMAIN][config_entry.entry_id]["api"] devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] - sensors = [] + entities = [ + RingBinarySensor(config_entry.entry_id, ring, device, description) + for device_type in ("doorbots", "authorized_doorbots", "stickup_cams") + for description in BINARY_SENSOR_TYPES + if device_type in description.category + for device in devices[device_type] + ] - for device_type in ("doorbots", "authorized_doorbots", "stickup_cams"): - for sensor_type, sensor in SENSOR_TYPES.items(): - if device_type not in sensor[1]: - continue - - for device in devices[device_type]: - sensors.append( - RingBinarySensor(config_entry.entry_id, ring, device, sensor_type) - ) - - async_add_entities(sensors) + async_add_entities(entities) class RingBinarySensor(RingEntityMixin, BinarySensorEntity): """A binary sensor implementation for Ring device.""" _active_alert = None + entity_description: RingBinarySensorEntityDescription - def __init__(self, config_entry_id, ring, device, sensor_type): + def __init__( + self, + config_entry_id, + ring, + device, + description: RingBinarySensorEntityDescription, + ): """Initialize a sensor for Ring device.""" super().__init__(config_entry_id, device) + self.entity_description = description self._ring = ring - self._sensor_type = sensor_type - self._name = f"{self._device.name} {SENSOR_TYPES.get(sensor_type)[0]}" - self._device_class = SENSOR_TYPES.get(sensor_type)[2] - self._state = None - self._unique_id = f"{device.id}-{sensor_type}" + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{device.id}-{description.key}" self._update_alert() async def async_added_to_hass(self): @@ -84,32 +109,17 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity): ( alert for alert in self._ring.active_alerts() - if alert["kind"] == self._sensor_type + if alert["kind"] == self.entity_description.key and alert["doorbot_id"] == self._device.id ), None, ) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def is_on(self): """Return True if the binary sensor is on.""" return self._active_alert is not None - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._device_class - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 580fc71e141..6a4ef692c1e 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,13 +1,14 @@ """This component provides support to the Ring Door Bell camera.""" -import asyncio +from __future__ import annotations + from datetime import timedelta from itertools import chain import logging from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import requests +from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ATTR_ATTRIBUTION @@ -42,15 +43,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class RingCam(RingEntityMixin, Camera): """An implementation of a Ring Door Bell camera.""" - def __init__(self, config_entry_id, ffmpeg, device): + def __init__(self, config_entry_id, ffmpeg_manager, device): """Initialize a Ring Door Bell camera.""" super().__init__(config_entry_id, device) self._name = self._device.name - self._ffmpeg = ffmpeg + self._ffmpeg_manager = ffmpeg_manager self._last_event = None self._last_video_id = None self._video_url = None + self._image = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL async def async_added_to_hass(self): @@ -79,6 +81,7 @@ class RingCam(RingEntityMixin, Camera): self._last_event = None self._last_video_id = None self._video_url = None + self._image = None self._expires_at = dt_util.utcnow() self.async_write_ha_state() @@ -101,27 +104,29 @@ class RingCam(RingEntityMixin, Camera): "last_video_id": self._last_video_id, } - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" - ffmpeg = ImageFrame(self._ffmpeg.binary) - - if self._video_url is None: - return - - image = await asyncio.shield( - ffmpeg.get_image( + if self._image is None and self._video_url: + image = await ffmpeg.async_get_image( + self.hass, self._video_url, - output_format=IMAGE_JPEG, + width=width, + height=height, ) - ) - return image + + if image: + self._image = image + + return self._image async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" if self._video_url is None: return - stream = CameraMjpeg(self._ffmpeg.binary) + stream = CameraMjpeg(self._ffmpeg_manager.binary) await stream.open_camera(self._video_url) try: @@ -130,7 +135,7 @@ class RingCam(RingEntityMixin, Camera): self.hass, request, stream_reader, - self._ffmpeg.ffmpeg_stream_content_type, + self._ffmpeg_manager.ffmpeg_stream_content_type, ) finally: await stream.close() @@ -147,6 +152,9 @@ class RingCam(RingEntityMixin, Camera): if self._last_video_id == self._last_event["id"] and utcnow <= self._expires_at: return + if self._last_video_id != self._last_event["id"]: + self._image = None + try: video_url = await self.hass.async_add_executor_job( self._device.recording_url, self._last_event["id"] diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index ecb64c99fd7..527fb143aff 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -2,7 +2,7 @@ "domain": "ring", "name": "Ring", "documentation": "https://www.home-assistant.io/integrations/ring", - "requirements": ["ring_doorbell==0.6.2"], + "requirements": ["ring_doorbell==0.7.1"], "dependencies": ["ffmpeg"], "codeowners": ["@balloob"], "config_flow": true, diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 97fb8ec9d21..c36b44f5ee5 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,5 +1,9 @@ """This component provides HA sensor support for Ring Door Bell/Chimes.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( DEVICE_CLASS_TIMESTAMP, PERCENTAGE, @@ -16,77 +20,58 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up a sensor for a Ring device.""" devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] - sensors = [] + entities = [ + description.cls(config_entry.entry_id, device, description) + for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams") + for description in SENSOR_TYPES + if device_type in description.category + for device in devices[device_type] + if not (device_type == "battery" and device.battery_life is None) + ] - for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams"): - for sensor_type, sensor in SENSOR_TYPES.items(): - if device_type not in sensor[1]: - continue - - for device in devices[device_type]: - if device_type == "battery" and device.battery_life is None: - continue - - sensors.append(sensor[6](config_entry.entry_id, device, sensor_type)) - - async_add_entities(sensors) + async_add_entities(entities) class RingSensor(RingEntityMixin, SensorEntity): """A sensor implementation for Ring device.""" - def __init__(self, config_entry_id, device, sensor_type): + entity_description: RingSensorEntityDescription + _attr_should_poll = False # updates are controlled via the hub + + def __init__( + self, + config_entry_id, + device, + description: RingSensorEntityDescription, + ): """Initialize a sensor for Ring device.""" super().__init__(config_entry_id, device) - self._sensor_type = sensor_type + self.entity_description = description self._extra = None - self._icon = f"mdi:{SENSOR_TYPES.get(sensor_type)[3]}" - self._kind = SENSOR_TYPES.get(sensor_type)[4] - self._name = f"{self._device.name} {SENSOR_TYPES.get(sensor_type)[0]}" - self._unique_id = f"{device.id}-{sensor_type}" + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{device.id}-{description.key}" @property - def should_poll(self): - """Return False, updates are controlled via the hub.""" - return False - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - if self._sensor_type == "volume": + sensor_type = self.entity_description.key + if sensor_type == "volume": return self._device.volume - if self._sensor_type == "battery": + if sensor_type == "battery": return self._device.battery_life - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def device_class(self): - """Return sensor device class.""" - return SENSOR_TYPES[self._sensor_type][5] - @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery" and self._device.battery_life is not None: + if ( + self.entity_description.key == "battery" + and self._device.battery_life is not None + ): return icon_for_battery_level( battery_level=self._device.battery_life, charging=False ) - return self._icon - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return SENSOR_TYPES.get(self._sensor_type)[2] + return self.entity_description.icon class HealthDataRingSensor(RingSensor): @@ -120,12 +105,13 @@ class HealthDataRingSensor(RingSensor): return False @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - if self._sensor_type == "wifi_signal_category": + sensor_type = self.entity_description.key + if sensor_type == "wifi_signal_category": return self._device.wifi_signal_category - if self._sensor_type == "wifi_signal_strength": + if sensor_type == "wifi_signal_strength": return self._device.wifi_signal_strength @@ -156,12 +142,13 @@ class HistoryRingSensor(RingSensor): if not history_data: return + kind = self.entity_description.kind found = None - if self._kind is None: + if kind is None: found = history_data[0] else: for entry in history_data: - if entry["kind"] == self._kind: + if entry["kind"] == kind: found = entry break @@ -172,7 +159,7 @@ class HistoryRingSensor(RingSensor): self.async_write_ha_state() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._latest_event is None: return None @@ -193,69 +180,77 @@ class HistoryRingSensor(RingSensor): return attrs -# Sensor types: Name, category, units, icon, kind, device_class, class -SENSOR_TYPES = { - "battery": [ - "Battery", - ["doorbots", "authorized_doorbots", "stickup_cams"], - PERCENTAGE, - None, - None, - "battery", - RingSensor, - ], - "last_activity": [ - "Last Activity", - ["doorbots", "authorized_doorbots", "stickup_cams"], - None, - "history", - None, - DEVICE_CLASS_TIMESTAMP, - HistoryRingSensor, - ], - "last_ding": [ - "Last Ding", - ["doorbots", "authorized_doorbots"], - None, - "history", - "ding", - DEVICE_CLASS_TIMESTAMP, - HistoryRingSensor, - ], - "last_motion": [ - "Last Motion", - ["doorbots", "authorized_doorbots", "stickup_cams"], - None, - "history", - "motion", - DEVICE_CLASS_TIMESTAMP, - HistoryRingSensor, - ], - "volume": [ - "Volume", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - None, - "bell-ring", - None, - None, - RingSensor, - ], - "wifi_signal_category": [ - "WiFi Signal Category", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - None, - "wifi", - None, - None, - HealthDataRingSensor, - ], - "wifi_signal_strength": [ - "WiFi Signal Strength", - ["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - "wifi", - None, - "signal_strength", - HealthDataRingSensor, - ], -} +@dataclass +class RingRequiredKeysMixin: + """Mixin for required keys.""" + + category: list[str] + cls: type[RingSensor] + + +@dataclass +class RingSensorEntityDescription(SensorEntityDescription, RingRequiredKeysMixin): + """Describes Ring sensor entity.""" + + kind: str | None = None + + +SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( + RingSensorEntityDescription( + key="battery", + name="Battery", + category=["doorbots", "authorized_doorbots", "stickup_cams"], + native_unit_of_measurement=PERCENTAGE, + device_class="battery", + cls=RingSensor, + ), + RingSensorEntityDescription( + key="last_activity", + name="Last Activity", + category=["doorbots", "authorized_doorbots", "stickup_cams"], + icon="mdi:history", + device_class=DEVICE_CLASS_TIMESTAMP, + cls=HistoryRingSensor, + ), + RingSensorEntityDescription( + key="last_ding", + name="Last Ding", + category=["doorbots", "authorized_doorbots"], + icon="mdi:history", + kind="ding", + device_class=DEVICE_CLASS_TIMESTAMP, + cls=HistoryRingSensor, + ), + RingSensorEntityDescription( + key="last_motion", + name="Last Motion", + category=["doorbots", "authorized_doorbots", "stickup_cams"], + icon="mdi:history", + kind="motion", + device_class=DEVICE_CLASS_TIMESTAMP, + cls=HistoryRingSensor, + ), + RingSensorEntityDescription( + key="volume", + name="Volume", + category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + icon="mdi:bell-ring", + cls=RingSensor, + ), + RingSensorEntityDescription( + key="wifi_signal_category", + name="WiFi Signal Category", + category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + icon="mdi:wifi", + cls=HealthDataRingSensor, + ), + RingSensorEntityDescription( + key="wifi_signal_strength", + name="WiFi Signal Strength", + category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + icon="mdi:wifi", + device_class="signal_strength", + cls=HealthDataRingSensor, + ), +) diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index f36e2c58ec8..2746f5789cd 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -46,12 +46,12 @@ class RippleSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index b39655949b2..0068e8c0f04 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -87,7 +87,7 @@ class RiscoSensor(CoordinatorEntity, SensorEntity): self.async_write_ha_state() @property - def state(self): + def native_value(self): """Value of sensor.""" if self._event is None: return None diff --git a/homeassistant/components/risco/translations/de.json b/homeassistant/components/risco/translations/de.json index a5ebcab51b5..77d842353fc 100644 --- a/homeassistant/components/risco/translations/de.json +++ b/homeassistant/components/risco/translations/de.json @@ -44,7 +44,7 @@ "B": "Gruppe B", "C": "Gruppe C", "D": "Gruppe D", - "arm": "Aktiv, abwesend", + "arm": "Aktiv (abwesend)", "partial_arm": "Teilweise aktiv (STAY)" }, "description": "W\u00e4hle aus, welchen Zustand dein Home Assistant-Alarm f\u00fcr jeden von Risco gemeldeten Zustand melden soll", diff --git a/homeassistant/components/risco/translations/nl.json b/homeassistant/components/risco/translations/nl.json index 5267b164f3f..6eeb5fff2e9 100644 --- a/homeassistant/components/risco/translations/nl.json +++ b/homeassistant/components/risco/translations/nl.json @@ -23,7 +23,7 @@ "ha_to_risco": { "data": { "armed_away": "Ingeschakeld weg", - "armed_custom_bypass": "Ingeschakeld met overbrugging(en)", + "armed_custom_bypass": "Ingeschakeld met overbrugging", "armed_home": "Ingeschakeld thuis", "armed_night": "Ingeschakeld nacht" }, diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index ee2a517a3f7..8a9ed5d94a3 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hublot = device.hublot coordinator = RitualsDataUpdateCoordinator(hass, device) - await coordinator.async_refresh() + await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id][DEVICES][hublot] = device hass.data[DOMAIN][entry.entry_id][COORDINATORS][hublot] = coordinator diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 7c957722384..878fb2f1a86 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from pyrituals import Diffuser +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -42,7 +43,7 @@ async def async_setup_entry( async_add_entities(entities) -class DiffuserPerfumeSensor(DiffuserEntity): +class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser perfume sensor.""" def __init__( @@ -59,12 +60,12 @@ class DiffuserPerfumeSensor(DiffuserEntity): return "mdi:tag-remove" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the perfume sensor.""" return self._diffuser.perfume -class DiffuserFillSensor(DiffuserEntity): +class DiffuserFillSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser fill sensor.""" def __init__( @@ -81,16 +82,16 @@ class DiffuserFillSensor(DiffuserEntity): return "mdi:beaker-question" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the fill sensor.""" return self._diffuser.fill -class DiffuserBatterySensor(DiffuserEntity): +class DiffuserBatterySensor(DiffuserEntity, SensorEntity): """Representation of a diffuser battery sensor.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator @@ -99,16 +100,16 @@ class DiffuserBatterySensor(DiffuserEntity): super().__init__(diffuser, coordinator, BATTERY_SUFFIX) @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the battery sensor.""" return self._diffuser.battery_percentage -class DiffuserWifiSensor(DiffuserEntity): +class DiffuserWifiSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser wifi sensor.""" _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator @@ -117,6 +118,6 @@ class DiffuserWifiSensor(DiffuserEntity): super().__init__(diffuser, coordinator, WIFI_SUFFIX) @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the wifi sensor.""" return self._diffuser.wifi_percentage diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 180c144a358..a213db4e5db 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities(entities) -class DiffuserSwitch(SwitchEntity, DiffuserEntity): +class DiffuserSwitch(DiffuserEntity, SwitchEntity): """Representation of a diffuser switch.""" _attr_icon = "mdi:fan" diff --git a/homeassistant/components/rituals_perfume_genie/translations/zh-Hans.json b/homeassistant/components/rituals_perfume_genie/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 9e4e7f3d588..bf2eab2d7b7 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -145,7 +145,7 @@ class RMVDepartureSensor(SensorEntity): return self._state is not None @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state @@ -171,7 +171,7 @@ class RMVDepartureSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/roku/translations/he.json b/homeassistant/components/roku/translations/he.json index 12dc4bb482b..41d59c29fd8 100644 --- a/homeassistant/components/roku/translations/he.json +++ b/homeassistant/components/roku/translations/he.json @@ -10,14 +10,6 @@ }, "flow_title": "{name}", "step": { - "ssdp_confirm": { - "data": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - } - }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/roku/translations/hu.json b/homeassistant/components/roku/translations/hu.json index 5485d9e00ce..b7aa12bfb4d 100644 --- a/homeassistant/components/roku/translations/hu.json +++ b/homeassistant/components/roku/translations/hu.json @@ -19,13 +19,18 @@ "title": "Roku" }, "ssdp_confirm": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + }, "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a {name}-t?", "title": "Roku" }, "user": { "data": { "host": "Hoszt" - } + }, + "description": "Adja meg Roku adatait." } } } diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 4fdcbceab07..f17eb0a07c0 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -175,17 +175,20 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="manual", description_placeholders={AUTH_HELP_URL_KEY: AUTH_HELP_URL_VALUE}, data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=self.host): str, - vol.Required(CONF_BLID, default=self.blid): str, - } + {vol.Required(CONF_HOST, default=self.host): str} ), ) self._async_abort_entries_match({CONF_HOST: user_input["host"]}) self.host = user_input[CONF_HOST] - self.blid = user_input[CONF_BLID].upper() + + devices = await _async_discover_roombas(self.hass, self.host) + if not devices: + return self.async_abort(reason="cannot_connect") + self.blid = devices[0].blid + self.name = devices[0].robot_name + await self.async_set_unique_id(self.blid, raise_on_progress=False) self._abort_if_unique_id_configured() return await self.async_step_link() diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 4a99d9f71af..bc20b4397e2 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -36,7 +36,7 @@ class RoombaBattery(IRobotEntity, SensorEntity): return DEVICE_CLASS_BATTERY @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement of the device.""" return PERCENTAGE @@ -50,6 +50,6 @@ class RoombaBattery(IRobotEntity, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._battery_level diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index 1a37745302a..b52d6443213 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -11,10 +11,9 @@ }, "manual": { "title": "Manually connect to the device", - "description": "No Roomba or Braava have been discovered on your network. The BLID is the portion of the device hostname after `iRobot-` or `Roomba-`. Please follow the steps outlined in the documentation at: {auth_help_url}", + "description": "No Roomba or Braava have been discovered on your network.", "data": { - "host": "[%key:common::config_flow::data::host%]", - "blid": "BLID" + "host": "[%key:common::config_flow::data::host%]" } }, "link": { diff --git a/homeassistant/components/roomba/translations/ca.json b/homeassistant/components/roomba/translations/ca.json index f237967b8a4..ba23e30e3f0 100644 --- a/homeassistant/components/roomba/translations/ca.json +++ b/homeassistant/components/roomba/translations/ca.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Amfitri\u00f3" }, - "description": "No s'ha descobert cap Roomba ni cap Braava a la teva xarxa. El BLID \u00e9s la part del nom d'amfitri\u00f3 del dispositiu despr\u00e9s de `iRobot-` o `Roomba-`. Segueix els passos de la documentaci\u00f3 seg\u00fcent: {auth_help_url}", + "description": "No s'ha descobert cap Roomba o Braava a la teva xarxa.", "title": "Connecta't al dispositiu manualment" }, "user": { diff --git a/homeassistant/components/roomba/translations/de.json b/homeassistant/components/roomba/translations/de.json index bfc98069881..ae4ef7d9dc7 100644 --- a/homeassistant/components/roomba/translations/de.json +++ b/homeassistant/components/roomba/translations/de.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "Es wurde kein Roomba oder Braava in deinem Netzwerk entdeckt. Die BLID ist der Teil des Ger\u00e4te-Hostnamens nach `iRobot-` oder `Roomba-`. Bitte folge den Schritten in der Dokumentation unter: {auth_help_url}", + "description": "Es wurde kein Roomba oder Braava in deinem Netzwerk entdeckt.", "title": "Manuell mit dem Ger\u00e4t verbinden" }, "user": { diff --git a/homeassistant/components/roomba/translations/en.json b/homeassistant/components/roomba/translations/en.json index df95782f52f..facd127985a 100644 --- a/homeassistant/components/roomba/translations/en.json +++ b/homeassistant/components/roomba/translations/en.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "No Roomba or Braava have been discovered on your network. The BLID is the portion of the device hostname after `iRobot-` or `Roomba-`. Please follow the steps outlined in the documentation at: {auth_help_url}", + "description": "No Roomba or Braava have been discovered on your network.", "title": "Manually connect to the device" }, "user": { diff --git a/homeassistant/components/roomba/translations/et.json b/homeassistant/components/roomba/translations/et.json index 7a8f33ebf57..43715399ef1 100644 --- a/homeassistant/components/roomba/translations/et.json +++ b/homeassistant/components/roomba/translations/et.json @@ -34,7 +34,7 @@ "blid": "", "host": "Host" }, - "description": "V\u00f5rgus ei tuvastatud \u00fchtegi Roomba ega Braava seadet. BLID on seadme hostinime osa p\u00e4rast 'iRobot-` v\u00f5i 'Roomba-'. J\u00e4rgi dokumentatsioonis toodud juhiseid: {auth_help_url}", + "description": "V\u00f5rgus ei tuvastatud \u00fchtegi Roomba ega Braava seadet.", "title": "\u00dchenda seadmega k\u00e4sitsi" }, "user": { diff --git a/homeassistant/components/roomba/translations/hu.json b/homeassistant/components/roomba/translations/hu.json index 2f8d902f4fe..0d76ce920b2 100644 --- a/homeassistant/components/roomba/translations/hu.json +++ b/homeassistant/components/roomba/translations/hu.json @@ -40,10 +40,12 @@ "user": { "data": { "blid": "BLID", + "continuous": "Folyamatos", "delay": "K\u00e9sleltet\u00e9s", "host": "Hoszt", "password": "Jelsz\u00f3" }, + "description": "V\u00e1lasszon Roomba-t vagy Braava-t.", "title": "Csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" } } diff --git a/homeassistant/components/roomba/translations/no.json b/homeassistant/components/roomba/translations/no.json index 1cacffdf425..3a5d95eb006 100644 --- a/homeassistant/components/roomba/translations/no.json +++ b/homeassistant/components/roomba/translations/no.json @@ -34,7 +34,7 @@ "blid": "", "host": "Vert" }, - "description": "Ingen Roomba eller Braava har blitt oppdaget i nettverket ditt. BLID er delen av enhetens vertsnavn etter `iRobot-` eller `Roomba-`. F\u00f8lg trinnene som er beskrevet i dokumentasjonen p\u00e5: {auth_help_url}", + "description": "Ingen Roomba eller Braava er oppdaget p\u00e5 nettverket ditt.", "title": "Koble til enheten manuelt" }, "user": { diff --git a/homeassistant/components/roomba/translations/pl.json b/homeassistant/components/roomba/translations/pl.json index 118d5a8ece7..5f6a3a23333 100644 --- a/homeassistant/components/roomba/translations/pl.json +++ b/homeassistant/components/roomba/translations/pl.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Nazwa hosta lub adres IP" }, - "description": "W Twojej sieci nie wykryto urz\u0105dzenia Roomba ani Braava. BLID to cz\u0119\u015b\u0107 nazwy hosta urz\u0105dzenia po `iRobot-` lub 'Roomba-'. Post\u0119puj zgodnie z instrukcjami podanymi w dokumentacji pod adresem: {auth_help_url}", + "description": "W Twojej sieci nie wykryto urz\u0105dzenia Roomba ani Braava.", "title": "R\u0119czne po\u0142\u0105czenie z urz\u0105dzeniem" }, "user": { diff --git a/homeassistant/components/roomba/translations/ru.json b/homeassistant/components/roomba/translations/ru.json index f61ecde08ec..a8b31297f04 100644 --- a/homeassistant/components/roomba/translations/ru.json +++ b/homeassistant/components/roomba/translations/ru.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "\u0425\u043e\u0441\u0442" }, - "description": "\u0412 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u0441\u0435\u0442\u0438 \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u043f\u044b\u043b\u0435\u0441\u043e\u0441\u043e\u0432 Roomba \u0438\u043b\u0438 Braava. BLID - \u044d\u0442\u043e \u0447\u0430\u0441\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0433\u043e \u0438\u043c\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0430\u044f \u043f\u043e\u0441\u043b\u0435 `iRobot-` \u0438\u043b\u0438 `Roomba-`. \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439: {auth_help_url}.", + "description": "\u0412 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u0441\u0435\u0442\u0438 \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 Roomba \u0438\u043b\u0438 Braava.", "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 \u0432\u0440\u0443\u0447\u043d\u0443\u044e" }, "user": { diff --git a/homeassistant/components/roomba/translations/zh-Hans.json b/homeassistant/components/roomba/translations/zh-Hans.json new file mode 100644 index 00000000000..7674a49c492 --- /dev/null +++ b/homeassistant/components/roomba/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "not_irobot_device": "\u5df2\u53d1\u73b0\u7684\u8bbe\u5907\u5e76\u4e0d\u662f iRobot \u8bbe\u5907" + }, + "step": { + "manual": { + "description": "\u672a\u5728\u60a8\u7684\u7f51\u7edc\u4e0a\u53d1\u73b0 Roomba \u6216 Braava\u3002 BLID \u662f\u8bbe\u5907\u4e3b\u673a\u540d\u4e2d \u201ciRobot-\u201d \u6216 \u201cRoomba-\u201d \u4e4b\u540e\u7684\u90e8\u5206\u3002\u8bf7\u6309\u7167\u6587\u6863\u4e2d\u6982\u8ff0\u7684\u6b65\u9aa4\u64cd\u4f5c\uff1a {auth_help_url}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roomba/translations/zh-Hant.json b/homeassistant/components/roomba/translations/zh-Hant.json index 4a5891d896e..c17607e8be4 100644 --- a/homeassistant/components/roomba/translations/zh-Hant.json +++ b/homeassistant/components/roomba/translations/zh-Hant.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "\u4e3b\u6a5f\u7aef" }, - "description": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Roomba \u6216 Braava\u3002BLID \u88dd\u7f6e\u65bc\u4e3b\u6a5f\u7aef\u7684\u90e8\u5206\u540d\u7a31\u70ba `iRobot-` \u6216 `Roomba-` \u958b\u982d\u3002\u8acb\u53c3\u95b1\u4ee5\u4e0b\u6587\u4ef6\u7684\u6b65\u9a5f\u9032\u884c\u8a2d\u5b9a\uff1a{auth_help_url}", + "description": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Roomba \u6216 Braava\u3002", "title": "\u624b\u52d5\u9023\u7dda\u81f3\u88dd\u7f6e" }, "user": { diff --git a/homeassistant/components/roon/translations/en_GB.json b/homeassistant/components/roon/translations/en_GB.json new file mode 100644 index 00000000000..246d2d9a6f1 --- /dev/null +++ b/homeassistant/components/roon/translations/en_GB.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "link": { + "description": "You must authorise Home Assistant in Roon. After you click submit, go to the Roon Core application, open Settings and enable HomeAssistant on the Extensions tab.", + "title": "Authorise HomeAssistant in Roon" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roon/translations/hu.json b/homeassistant/components/roon/translations/hu.json index 123027a8216..56a8ade165c 100644 --- a/homeassistant/components/roon/translations/hu.json +++ b/homeassistant/components/roon/translations/hu.json @@ -9,12 +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" }, "user": { "data": { "host": "Hoszt" - } + }, + "description": "Nem tal\u00e1lta a Roon szervert, adja meg a gazdag\u00e9p nev\u00e9t vagy IP-c\u00edm\u00e9t." } } } diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 35d8c0ae2c0..54e2c315a4e 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -116,7 +116,7 @@ class RovaSensor(SensorEntity): self.data_service.update() pickup_date = self.data_service.data.get(self.entity_description.key) if pickup_date is not None: - self._attr_state = pickup_date.isoformat() + self._attr_native_value = pickup_date.isoformat() class RovaData: diff --git a/homeassistant/components/rpi_camera/camera.py b/homeassistant/components/rpi_camera/camera.py index 070e861b3c9..980586d4def 100644 --- a/homeassistant/components/rpi_camera/camera.py +++ b/homeassistant/components/rpi_camera/camera.py @@ -1,4 +1,6 @@ """Camera platform that has a Raspberry Pi camera.""" +from __future__ import annotations + import logging import os import shutil @@ -122,7 +124,9 @@ class RaspberryCamera(Camera): ): pass - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return raspistill image response.""" with open(self._config[CONF_FILE_PATH], "rb") as file: return file.read() diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index c750c7aa83c..78dfca92525 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -1,10 +1,16 @@ """Support for monitoring the rtorrent BitTorrent client API.""" +from __future__ import annotations + import logging import xmlrpc.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 ( CONF_MONITORED_VARIABLES, CONF_NAME, @@ -28,24 +34,55 @@ SENSOR_TYPE_DOWNLOADING_TORRENTS = "downloading_torrents" SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" DEFAULT_NAME = "rtorrent" -SENSOR_TYPES = { - SENSOR_TYPE_CURRENT_STATUS: ["Status", None], - SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], - SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], - SENSOR_TYPE_ALL_TORRENTS: ["All Torrents", None], - SENSOR_TYPE_STOPPED_TORRENTS: ["Stopped Torrents", None], - SENSOR_TYPE_COMPLETE_TORRENTS: ["Complete Torrents", None], - SENSOR_TYPE_UPLOADING_TORRENTS: ["Uploading Torrents", None], - SENSOR_TYPE_DOWNLOADING_TORRENTS: ["Downloading Torrents", None], - SENSOR_TYPE_ACTIVE_TORRENTS: ["Active Torrents", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TYPE_CURRENT_STATUS, + name="Status", + ), + SensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED, + name="Down Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED, + name="Up Speed", + native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + ), + SensorEntityDescription( + key=SENSOR_TYPE_ALL_TORRENTS, + name="All Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_STOPPED_TORRENTS, + name="Stopped Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_COMPLETE_TORRENTS, + name="Complete Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_UPLOADING_TORRENTS, + name="Uploading Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_DOWNLOADING_TORRENTS, + name="Downloading Torrents", + ), + SensorEntityDescription( + key=SENSOR_TYPE_ACTIVE_TORRENTS, + name="Active Torrents", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): cv.url, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=list(SENSOR_TYPES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + vol.Optional(CONF_MONITORED_VARIABLES, default=SENSOR_KEYS): vol.All( + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -61,11 +98,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except (xmlrpc.client.ProtocolError, ConnectionRefusedError) as ex: _LOGGER.error("Connection to rtorrent daemon failed") raise PlatformNotReady from ex - dev = [] - for variable in config[CONF_MONITORED_VARIABLES]: - dev.append(RTorrentSensor(variable, rtorrent, name)) + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + RTorrentSensor(rtorrent, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] - add_entities(dev) + add_entities(entities) def format_speed(speed): @@ -77,36 +117,16 @@ def format_speed(speed): class RTorrentSensor(SensorEntity): """Representation of an rtorrent sensor.""" - def __init__(self, sensor_type, rtorrent_client, client_name): + def __init__( + self, rtorrent_client, client_name, description: SensorEntityDescription + ): """Initialize the sensor.""" - self._name = SENSOR_TYPES[sensor_type][0] + self.entity_description = description self.client = rtorrent_client - self.type = sensor_type - self.client_name = client_name - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self.data = None - self._available = False - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return true if device is available.""" - return self._available - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{client_name} {description.name}" + self._attr_available = False def update(self): """Get the latest data from rtorrent and updates the state.""" @@ -121,10 +141,10 @@ class RTorrentSensor(SensorEntity): try: self.data = multicall() - self._available = True + self._attr_available = True except (xmlrpc.client.ProtocolError, OSError) as ex: _LOGGER.error("Connection to rtorrent failed (%s)", ex) - self._available = False + self._attr_available = False return upload = self.data[0] @@ -145,33 +165,34 @@ class RTorrentSensor(SensorEntity): active_torrents = uploading_torrents + downloading_torrents - if self.type == SENSOR_TYPE_CURRENT_STATUS: + sensor_type = self.entity_description.key + if sensor_type == SENSOR_TYPE_CURRENT_STATUS: if self.data: if upload > 0 and download > 0: - self._state = "up_down" + self._attr_native_value = "up_down" elif upload > 0 and download == 0: - self._state = "seeding" + self._attr_native_value = "seeding" elif upload == 0 and download > 0: - self._state = "downloading" + self._attr_native_value = "downloading" else: - self._state = STATE_IDLE + self._attr_native_value = STATE_IDLE else: - self._state = None + self._attr_native_value = None if self.data: - if self.type == SENSOR_TYPE_DOWNLOAD_SPEED: - self._state = format_speed(download) - elif self.type == SENSOR_TYPE_UPLOAD_SPEED: - self._state = format_speed(upload) - elif self.type == SENSOR_TYPE_ALL_TORRENTS: - self._state = len(all_torrents) - elif self.type == SENSOR_TYPE_STOPPED_TORRENTS: - self._state = len(stopped_torrents) - elif self.type == SENSOR_TYPE_COMPLETE_TORRENTS: - self._state = len(complete_torrents) - elif self.type == SENSOR_TYPE_UPLOADING_TORRENTS: - self._state = uploading_torrents - elif self.type == SENSOR_TYPE_DOWNLOADING_TORRENTS: - self._state = downloading_torrents - elif self.type == SENSOR_TYPE_ACTIVE_TORRENTS: - self._state = active_torrents + if sensor_type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._attr_native_value = format_speed(download) + elif sensor_type == SENSOR_TYPE_UPLOAD_SPEED: + self._attr_native_value = format_speed(upload) + elif sensor_type == SENSOR_TYPE_ALL_TORRENTS: + self._attr_native_value = len(all_torrents) + elif sensor_type == SENSOR_TYPE_STOPPED_TORRENTS: + self._attr_native_value = len(stopped_torrents) + elif sensor_type == SENSOR_TYPE_COMPLETE_TORRENTS: + self._attr_native_value = len(complete_torrents) + elif sensor_type == SENSOR_TYPE_UPLOADING_TORRENTS: + self._attr_native_value = uploading_torrents + elif sensor_type == SENSOR_TYPE_DOWNLOADING_TORRENTS: + self._attr_native_value = downloading_torrents + elif sensor_type == SENSOR_TYPE_ACTIVE_TORRENTS: + self._attr_native_value = active_torrents diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 8574e82aa47..a420ca53814 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,4 +1,6 @@ """Support for monitoring an SABnzbd NZB client.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -31,7 +33,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "sabnzbd" DATA_SABNZBD = "sabznbd" -_CONFIGURING = {} +_CONFIGURING: dict[str, str] = {} ATTR_SPEED = "speed" BASE_URL_FORMAT = "{}://{}:{}/" diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index c0930f2c114..ffe57e608bf 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -45,7 +45,7 @@ class SabnzbdSensor(SensorEntity): return f"{self._client_name} {self._name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -55,7 +55,7 @@ class SabnzbdSensor(SensorEntity): return False @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/safe_mode/__init__.py b/homeassistant/components/safe_mode/__init__.py index 94bd95aabe0..162dd204c54 100644 --- a/homeassistant/components/safe_mode/__init__.py +++ b/homeassistant/components/safe_mode/__init__.py @@ -1,11 +1,12 @@ """The Safe Mode integration.""" from homeassistant.components import persistent_notification from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType DOMAIN = "safe_mode" -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Safe Mode component.""" persistent_notification.async_create( hass, diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 1b46632051e..8e59899de27 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -34,7 +35,6 @@ from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -177,10 +177,10 @@ class SAJsensor(SensorEntity): self._serialnumber = serialnumber self._state = self._sensor.value - if pysaj_sensor.name in ("current_power", "total_yield", "temperature"): + if pysaj_sensor.name in ("current_power", "temperature"): self._attr_state_class = STATE_CLASS_MEASUREMENT if pysaj_sensor.name == "total_yield": - self._attr_last_reset = dt_util.utc_from_timestamp(0) + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING @property def name(self): @@ -191,12 +191,12 @@ class SAJsensor(SensorEntity): return f"saj_{self._sensor.name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return SAJ_UNIT_MAPPINGS[self._sensor.unit] diff --git a/homeassistant/components/samsungtv/translations/zh-Hans.json b/homeassistant/components/samsungtv/translations/zh-Hans.json new file mode 100644 index 00000000000..da6a5c3c9ba --- /dev/null +++ b/homeassistant/components/samsungtv/translations/zh-Hans.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u5df2\u5728\u8fdb\u884c\u4e2d", + "auth_missing": "Home Assistant \u672a\u88ab\u5141\u8bb8\u8fde\u63a5\u6b64\u4e09\u661f\u7535\u89c6\u3002\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u89c6\u8bbe\u7f6e\u3002", + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "id_missing": "\u6b64\u4e09\u661f\u8bbe\u5907\u6ca1\u6709\u5e8f\u5217\u53f7\u3002", + "not_supported": "\u6b64\u4e09\u661f\u8bbe\u5907\u76ee\u524d\u6682\u4e0d\u652f\u6301\u3002", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "auth_missing": "Home Assistant \u672a\u88ab\u5141\u8bb8\u8fde\u63a5\u6b64\u4e09\u661f\u7535\u89c6\u3002\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u89c6\u8bbe\u7f6e\u3002" + }, + "flow_title": "{device}", + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u914d\u7f6e {device} ?\n\u5982\u679c\u60a8\u4e4b\u524d\u4ece\u672a\u8fde\u63a5\u8fc7 Home Assistant \uff0c\u60a8\u5c06\u4f1a\u5728\u8be5\u7535\u89c6\u4e0a\u770b\u5230\u8bf7\u6c42\u6388\u6743\u7684\u5f39\u7a97\u3002", + "title": "\u4e09\u661f\u7535\u89c6" + }, + "reauth_confirm": { + "description": "\u63d0\u4ea4\u4fe1\u606f\u540e\uff0c\u8bf7\u5728 30 \u79d2\u5185\u5728 {device} \u540c\u610f\u83b7\u53d6\u76f8\u5173\u6388\u6743\u3002" + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u7528\u6237\u540d" + }, + "description": "\u8f93\u5165\u60a8\u7684\u4e09\u661f\u7535\u89c6\u4fe1\u606f\u3002\u5982\u679c\u60a8\u4e4b\u524d\u4ece\u672a\u8fde\u63a5\u8fc7 Home Assistant \uff0c\u60a8\u5c06\u4f1a\u5728\u8be5\u7535\u89c6\u4e0a\u770b\u5230\u8bf7\u6c42\u6388\u6743\u7684\u5f39\u7a97\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 921ab29f714..1f5b543f9ee 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -108,12 +108,12 @@ class ScrapeSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 223ca9262ee..2ec087d1e61 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -31,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = ["switch", "sensor", "binary_sensor", "climate"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Screenlogic component.""" domain_data = hass.data[DOMAIN] = {} domain_data[DISCOVERED_GATEWAYS] = await async_discover_gateways_by_unique_id(hass) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 1ad18298655..c8e4f84caf0 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -114,7 +114,7 @@ class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): return f"{self.gateway_name} {self.sensor['name']}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.sensor.get("unit") @@ -125,7 +125,7 @@ class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) @property - def state(self): + def native_value(self): """State of the sensor.""" value = self.sensor["value"] return (value - 1) if "supply" in self._data_key else value @@ -160,7 +160,7 @@ class ScreenLogicChemistrySensor(ScreenLogicSensor): self._key = key @property - def state(self): + def native_value(self): """State of the sensor.""" value = self.sensor["value"] if "dosing_state" in self._key: diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 483b4065be2..f3f34a0ad53 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -32,6 +32,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( ATTR_CUR, ATTR_MAX, @@ -42,6 +43,7 @@ from homeassistant.helpers.script import ( from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.trace import trace_get, trace_path from homeassistant.loader import bind_hass +from homeassistant.util.dt import parse_datetime from .config import ScriptConfig, async_validate_config_item from .const import ( @@ -296,7 +298,7 @@ async def _async_process_config(hass, config, component) -> bool: return blueprints_used -class ScriptEntity(ToggleEntity): +class ScriptEntity(ToggleEntity, RestoreEntity): """Representation of a script entity.""" icon = None @@ -415,6 +417,12 @@ class ScriptEntity(ToggleEntity): """ await self.script.async_stop() + async def async_added_to_hass(self) -> None: + """Restore last triggered on startup.""" + if state := await self.async_get_last_state(): + if last_triggered := state.attributes.get("last_triggered"): + self.script.last_triggered = parse_datetime(last_triggered) + async def async_will_remove_from_hass(self): """Stop script and remove service when it will be removed from Home Assistant.""" await self.script.async_stop() diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index fc13b8ca098..5472ac421c3 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -11,12 +11,13 @@ from homeassistant.components.homeassistant import scene from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.entity import entity_sources as get_entity_sources +from homeassistant.helpers.typing import ConfigType DOMAIN = "search" _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Search component.""" websocket_api.async_register_command(hass, websocket_search_related) return True diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index 165920dd8e5..80fb71f594b 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -126,7 +126,7 @@ class Season(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the current season.""" return self.season diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index d5c70c76cd0..9a7bfa62cdf 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -9,7 +9,7 @@ from typing import Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -40,12 +40,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SELECT_OPTION, {vol.Required(ATTR_OPTION): cv.string}, - "async_select_option", + async_select_option, ) return True +async def async_select_option(entity: SelectEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + option = service_call.data[ATTR_OPTION] + if option not in entity.options: + raise ValueError(f"Option {option} not valid for {entity.name}") + await entity.async_select_option(option) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent = hass.data[DOMAIN] diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index ece3c981690..ca9c1963782 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -1,8 +1,6 @@ """Provides device actions for Select.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( @@ -31,7 +29,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Select devices.""" registry = await entity_registry.async_get_registry(hass) return [ @@ -64,7 +64,7 @@ async def async_call_action_from_config( async def async_get_action_capabilities( hass: HomeAssistant, config: ConfigType -) -> dict[str, Any]: +) -> dict[str, vol.Schema]: """List action capabilities.""" try: options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] diff --git a/homeassistant/components/select/device_condition.py b/homeassistant/components/select/device_condition.py index ad82c432ce2..4f650ddadda 100644 --- a/homeassistant/components/select/device_condition.py +++ b/homeassistant/components/select/device_condition.py @@ -1,8 +1,6 @@ """Provide the device conditions for Select.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.const import ( @@ -21,6 +19,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_OPTIONS, CONF_OPTION, DOMAIN +# nypy: disallow-any-generics + CONDITION_TYPES = {"selected_option"} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( @@ -71,7 +71,7 @@ def async_condition_from_config( async def async_get_condition_capabilities( hass: HomeAssistant, config: ConfigType -) -> dict[str, Any]: +) -> dict[str, vol.Schema]: """List condition capabilities.""" try: options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index 84f61dfaec9..ded3ff4bc24 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -42,7 +42,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Select devices.""" registry = await entity_registry.async_get_registry(hass) return [ @@ -87,7 +89,7 @@ async def async_attach_trigger( async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType -) -> dict[str, Any]: +) -> dict[str, vol.Schema]: """List trigger capabilities.""" try: options = get_capability(hass, config[CONF_ENTITY_ID], ATTR_OPTIONS) or [] diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index 783fcb5508a..af8454bbeab 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -23,6 +23,16 @@ CONSUMPTION_NAME = "Usage" CONSUMPTION_ID = "usage" PRODUCTION_NAME = "Production" PRODUCTION_ID = "production" +PRODUCTION_PCT_NAME = "Net Production Percentage" +PRODUCTION_PCT_ID = "production_pct" +NET_PRODUCTION_NAME = "Net Production" +NET_PRODUCTION_ID = "net_production" +TO_GRID_NAME = "To Grid" +TO_GRID_ID = "to_grid" +FROM_GRID_NAME = "From Grid" +FROM_GRID_ID = "from_grid" +SOLAR_POWERED_NAME = "Solar Powered Percentage" +SOLAR_POWERED_ID = "solar_powered" ICON = "mdi:flash" diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 0bde2f7a7a7..16cecd1cd97 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -2,7 +2,7 @@ "domain": "sense", "name": "Sense", "documentation": "https://www.home-assistant.io/integrations/sense", - "requirements": ["sense_energy==0.9.0"], + "requirements": ["sense_energy==0.9.2"], "codeowners": ["@kbickar"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 5a352969c3b..ce22551eff2 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -1,18 +1,20 @@ """Support for monitoring a Sense energy sensor.""" -import datetime - -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, + PERCENTAGE, POWER_WATT, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -import homeassistant.util.dt as dt_util from .const import ( ACTIVE_NAME, @@ -21,15 +23,25 @@ from .const import ( CONSUMPTION_ID, CONSUMPTION_NAME, DOMAIN, + FROM_GRID_ID, + FROM_GRID_NAME, ICON, MDI_ICONS, + NET_PRODUCTION_ID, + NET_PRODUCTION_NAME, PRODUCTION_ID, PRODUCTION_NAME, + PRODUCTION_PCT_ID, + PRODUCTION_PCT_NAME, SENSE_DATA, SENSE_DEVICE_UPDATE, SENSE_DEVICES_DATA, SENSE_DISCOVERED_DEVICES_DATA, SENSE_TRENDS_COORDINATOR, + SOLAR_POWERED_ID, + SOLAR_POWERED_NAME, + TO_GRID_ID, + TO_GRID_NAME, ) @@ -54,7 +66,16 @@ TRENDS_SENSOR_TYPES = { } # Production/consumption variants -SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID] +SENSOR_VARIANTS = [(PRODUCTION_ID, PRODUCTION_NAME), (CONSUMPTION_ID, CONSUMPTION_NAME)] + +# Trend production/consumption variants +TREND_SENSOR_VARIANTS = SENSOR_VARIANTS + [ + (PRODUCTION_PCT_ID, PRODUCTION_PCT_NAME), + (NET_PRODUCTION_ID, NET_PRODUCTION_NAME), + (FROM_GRID_ID, FROM_GRID_NAME), + (TO_GRID_ID, TO_GRID_NAME), + (SOLAR_POWERED_ID, SOLAR_POWERED_NAME), +] def sense_to_mdi(sense_icon): @@ -85,15 +106,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if device["tags"]["DeviceListAllowed"] == "true" ] - for var in SENSOR_VARIANTS: + for variant_id, variant_name in SENSOR_VARIANTS: name = ACTIVE_SENSOR_TYPE.name sensor_type = ACTIVE_SENSOR_TYPE.sensor_type - is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-active-{var}" + unique_id = f"{sense_monitor_id}-active-{variant_id}" devices.append( SenseActiveSensor( - data, name, sensor_type, is_production, sense_monitor_id, var, unique_id + data, + name, + sensor_type, + sense_monitor_id, + variant_id, + variant_name, + unique_id, ) ) @@ -101,18 +127,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices.append(SenseVoltageSensor(data, i, sense_monitor_id)) for type_id, typ in TRENDS_SENSOR_TYPES.items(): - for var in SENSOR_VARIANTS: + for variant_id, variant_name in TREND_SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type - is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-{type_id}-{var}" + unique_id = f"{sense_monitor_id}-{type_id}-{variant_id}" devices.append( SenseTrendsSensor( data, name, sensor_type, - is_production, + variant_id, + variant_name, trends_coordinator, unique_id, ) @@ -125,7 +151,7 @@ class SenseActiveSensor(SensorEntity): """Implementation of a Sense energy sensor.""" _attr_icon = ICON - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_should_poll = False _attr_available = False @@ -136,19 +162,19 @@ class SenseActiveSensor(SensorEntity): data, name, sensor_type, - is_production, sense_monitor_id, - sensor_id, + variant_id, + variant_name, unique_id, ): """Initialize the Sense sensor.""" - name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._attr_name = f"{name} {name_type}" + self._attr_name = f"{name} {variant_name}" self._attr_unique_id = unique_id self._data = data self._sense_monitor_id = sense_monitor_id self._sensor_type = sensor_type - self._is_production = is_production + self._variant_id = variant_id + self._variant_name = variant_name async def async_added_to_hass(self): """Register callbacks.""" @@ -165,12 +191,12 @@ class SenseActiveSensor(SensorEntity): """Update the sensor from the data. Must not do I/O.""" new_state = round( self._data.active_solar_power - if self._is_production + if self._variant_id == PRODUCTION_ID else self._data.active_power ) - if self._attr_available and self._attr_state == new_state: + if self._attr_available and self._attr_native_value == new_state: return - self._attr_state = new_state + self._attr_native_value = new_state self._attr_available = True self.async_write_ha_state() @@ -178,7 +204,7 @@ class SenseActiveSensor(SensorEntity): class SenseVoltageSensor(SensorEntity): """Implementation of a Sense energy voltage sensor.""" - _attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT + _attr_native_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON _attr_should_poll = False @@ -212,10 +238,10 @@ class SenseVoltageSensor(SensorEntity): def _async_update_from_data(self): """Update the sensor from the data. Must not do I/O.""" new_state = round(self._data.active_voltage[self._voltage_index], 1) - if self._attr_available and self._attr_state == new_state: + if self._attr_available and self._attr_native_value == new_state: return self._attr_available = True - self._attr_state = new_state + self._attr_native_value = new_state self.async_write_ha_state() @@ -223,8 +249,8 @@ class SenseTrendsSensor(SensorEntity): """Implementation of a Sense energy sensor.""" _attr_device_class = DEVICE_CLASS_ENERGY - _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON _attr_should_poll = False @@ -234,37 +260,36 @@ class SenseTrendsSensor(SensorEntity): data, name, sensor_type, - is_production, + variant_id, + variant_name, trends_coordinator, unique_id, ): """Initialize the Sense sensor.""" - name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._attr_name = f"{name} {name_type}" + self._attr_name = f"{name} {variant_name}" self._attr_unique_id = unique_id self._data = data self._sensor_type = sensor_type self._coordinator = trends_coordinator - self._is_production = is_production + self._variant_id = variant_id self._had_any_update = False + if variant_id in [PRODUCTION_PCT_ID, SOLAR_POWERED_ID]: + self._attr_native_unit_of_measurement = PERCENTAGE + self._attr_entity_registry_enabled_default = False + self._attr_state_class = None + self._attr_device_class = None + @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - return round(self._data.get_trend(self._sensor_type, self._is_production), 1) + return round(self._data.get_trend(self._sensor_type, self._variant_id), 1) @property def available(self): """Return if entity is available.""" return self._had_any_update and self._coordinator.last_update_success - @property - def last_reset(self) -> datetime.datetime: - """Return the time when the sensor was last reset, if any.""" - if self._sensor_type == "DAY": - return dt_util.start_of_local_day() - return None - @callback def _async_update(self): """Track if we had an update so we do not report zero data.""" @@ -288,7 +313,7 @@ class SenseEnergyDevice(SensorEntity): _attr_available = False _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_device_class = DEVICE_CLASS_POWER _attr_should_poll = False @@ -320,8 +345,8 @@ class SenseEnergyDevice(SensorEntity): new_state = 0 else: new_state = int(device_data["w"]) - if self._attr_available and self._attr_state == new_state: + if self._attr_available and self._attr_native_value == new_state: return - self._attr_state = new_state + self._attr_native_value = new_state self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/sense/translations/hu.json b/homeassistant/components/sense/translations/hu.json index 4ecaf2ba0d0..acd67b9e6f9 100644 --- a/homeassistant/components/sense/translations/hu.json +++ b/homeassistant/components/sense/translations/hu.json @@ -13,7 +13,8 @@ "data": { "email": "E-mail", "password": "Jelsz\u00f3" - } + }, + "title": "Csatlakoztassa a Sense Energy Monitort" } } } diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py index 379301b0fa7..8dc74ae4e08 100644 --- a/homeassistant/components/sensehat/sensor.py +++ b/homeassistant/components/sensehat/sensor.py @@ -1,4 +1,6 @@ """Support for Sense HAT sensors.""" +from __future__ import annotations + from datetime import timedelta import logging from pathlib import Path @@ -6,7 +8,11 @@ from pathlib import Path from sense_hat import SenseHat 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_DISPLAY_OPTIONS, CONF_NAME, @@ -24,17 +30,30 @@ CONF_IS_HAT_ATTACHED = "is_hat_attached" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -SENSOR_TYPES = { - "temperature": ["temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "humidity": ["humidity", PERCENTAGE, None], - "pressure": ["pressure", "mb", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="humidity", + name="humidity", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="pressure", + name="pressure", + native_unit_of_measurement="mb", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [ - vol.In(SENSOR_TYPES) - ], + vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_KEYS): [vol.In(SENSOR_KEYS)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_IS_HAT_ATTACHED, default=True): cv.boolean, } @@ -61,39 +80,23 @@ def get_average(temp_base): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Sense HAT sensor platform.""" data = SenseHatData(config.get(CONF_IS_HAT_ATTACHED)) - dev = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - dev.append(SenseHatSensor(data, variable)) + display_options = config[CONF_DISPLAY_OPTIONS] + entities = [ + SenseHatSensor(data, description) + for description in SENSOR_TYPES + if description.key in display_options + ] - add_entities(dev, True) + add_entities(entities, True) class SenseHatSensor(SensorEntity): """Representation of a Sense HAT sensor.""" - def __init__(self, data, sensor_types): + def __init__(self, data, description: SensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.data = data - self._name = SENSOR_TYPES[sensor_types][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_types][1] - self.type = sensor_types - self._state = None - self._attr_device_class = SENSOR_TYPES[sensor_types][2] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement def update(self): """Get the latest data and updates the states.""" @@ -102,12 +105,13 @@ class SenseHatSensor(SensorEntity): _LOGGER.error("Don't receive data") return - if self.type == "temperature": - self._state = self.data.temperature - if self.type == "humidity": - self._state = self.data.humidity - if self.type == "pressure": - self._state = self.data.pressure + sensor_type = self.entity_description.key + if sensor_type == "temperature": + self._attr_native_value = self.data.temperature + elif sensor_type == "humidity": + self._attr_native_value = self.data.humidity + elif sensor_type == "pressure": + self._attr_native_value = self.data.pressure class SenseHatData: diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index d34ea040cdc..c4589205e34 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -357,7 +357,7 @@ class SensiboClimate(ClimateEntity): if change_needed: await self._async_set_ac_state_property("on", state != HVAC_MODE_OFF, True) - if state in [STATE_ON, HVAC_MODE_OFF]: + if state in (STATE_ON, HVAC_MODE_OFF): self._external_state = None else: self._external_state = state diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e36640b1c1d..fafaabbd217 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations from collections.abc import Mapping +from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta +import inspect import logging from typing import Any, Final, cast, final @@ -11,21 +13,34 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CO, DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_MONETARY, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLTAGE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -34,11 +49,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, StateType _LOGGER: Final = logging.getLogger(__name__) -ATTR_LAST_RESET: Final = "last_reset" +ATTR_LAST_RESET: Final = "last_reset" # Deprecated, to be removed in 2021.11 ATTR_STATE_CLASS: Final = "state_class" DOMAIN: Final = "sensor" @@ -47,6 +62,7 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" SCAN_INTERVAL: Final = timedelta(seconds=30) DEVICE_CLASSES: Final[list[str]] = [ + DEVICE_CLASS_AQI, # Air Quality Index DEVICE_CLASS_BATTERY, # % of battery that is left DEVICE_CLASS_CO, # ppm (parts per million) Carbon Monoxide gas concentration DEVICE_CLASS_CO2, # ppm (parts per million) Carbon Dioxide gas concentration @@ -55,21 +71,36 @@ DEVICE_CLASSES: Final[list[str]] = [ 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_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³) DEVICE_CLASS_SIGNAL_STRENGTH, # signal strength (dB/dBm) + DEVICE_CLASS_SULPHUR_DIOXIDE, # Amount of SO2 (µg/m³) DEVICE_CLASS_TEMPERATURE, # temperature (C/F) DEVICE_CLASS_TIMESTAMP, # timestamp (ISO8601) DEVICE_CLASS_PRESSURE, # pressure (hPa/mbar) DEVICE_CLASS_POWER, # power (W/kW) DEVICE_CLASS_POWER_FACTOR, # power factor (%) DEVICE_CLASS_VOLTAGE, # voltage (V) + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, # Amount of VOC (µg/m³) + DEVICE_CLASS_GAS, # gas (m³ or ft³) ] 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 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_CLASSES: Final[list[str]] = [ + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +] STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.In(STATE_CLASSES)) @@ -100,16 +131,47 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class SensorEntityDescription(EntityDescription): """A class that describes sensor entities.""" + last_reset: datetime | None = None # Deprecated, to be removed in 2021.11 + native_unit_of_measurement: str | None = None state_class: str | None = None - last_reset: datetime | None = None + unit_of_measurement: None = None # Type override, use native_unit_of_measurement + + def __post_init__(self) -> None: + """Post initialisation processing.""" + if self.unit_of_measurement: + caller = inspect.stack()[2] # type: ignore[unreachable] + module = inspect.getmodule(caller[0]) + if "custom_components" in module.__file__: + 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" + ) + _LOGGER.warning( + "%s is setting 'unit_of_measurement' on an instance of " + "SensorEntityDescription, this is not valid and will be unsupported " + "from Home Assistant 2021.11. Please %s", + module.__name__, + report_issue, + ) + self.native_unit_of_measurement = self.unit_of_measurement class SensorEntity(Entity): """Base class for sensor entities.""" entity_description: SensorEntityDescription + _attr_last_reset: datetime | None # Deprecated, to be removed in 2021.11 + _attr_native_unit_of_measurement: str | None + _attr_native_value: StateType = None _attr_state_class: str | None - _attr_last_reset: datetime | None + _attr_state: None = None # Subclasses of SensorEntity should not set this + _attr_unit_of_measurement: None = ( + None # Subclasses of SensorEntity should not set this + ) + _last_reset_reported = False + _temperature_conversion_reported = False @property def state_class(self) -> str | None: @@ -121,7 +183,7 @@ class SensorEntity(Entity): return None @property - def last_reset(self) -> datetime | None: + def last_reset(self) -> datetime | None: # Deprecated, to be removed in 2021.11 """Return the time when the sensor was last reset, if any.""" if hasattr(self, "_attr_last_reset"): return self._attr_last_reset @@ -142,6 +204,108 @@ class SensorEntity(Entity): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" if last_reset := self.last_reset: + if ( + self.state_class == STATE_CLASS_MEASUREMENT + and not self._last_reset_reported + ): + self._last_reset_reported = True + 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", + self.entity_id, + type(self), + self.state_class, + report_issue, + ) + return {ATTR_LAST_RESET: last_reset.isoformat()} return None + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self._attr_native_value + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor, if any.""" + if hasattr(self, "_attr_native_unit_of_measurement"): + return self._attr_native_unit_of_measurement + if hasattr(self, "entity_description"): + return self.entity_description.native_unit_of_measurement + return None + + @final + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the entity, after unit conversion.""" + # Support for _attr_unit_of_measurement will be removed in Home Assistant 2021.11 + if ( + hasattr(self, "_attr_unit_of_measurement") + and self._attr_unit_of_measurement is not None + ): + return self._attr_unit_of_measurement # type: ignore + + native_unit_of_measurement = self.native_unit_of_measurement + + if native_unit_of_measurement in (TEMP_CELSIUS, TEMP_FAHRENHEIT): + return self.hass.config.units.temperature_unit + + return native_unit_of_measurement + + @final + @property + def state(self) -> Any: + """Return the state of the sensor and perform unit conversions, if needed.""" + unit_of_measurement = self.native_unit_of_measurement + value = self.native_value + + units = self.hass.config.units + if ( + value is not None + and unit_of_measurement in (TEMP_CELSIUS, TEMP_FAHRENHEIT) + and unit_of_measurement != units.temperature_unit + ): + if ( + self.device_class != DEVICE_CLASS_TEMPERATURE + and not self._temperature_conversion_reported + ): + self._temperature_conversion_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s (%s) with device_class %s reports a temperature in " + "%s which will be converted to %s. Temperature conversion for " + "entities without correct device_class is deprecated and will" + " be removed from Home Assistant Core 2022.3. Please update " + "your configuration if device_class is manually configured, " + "otherwise %s", + self.entity_id, + type(self), + self.device_class, + unit_of_measurement, + units.temperature_unit, + report_issue, + ) + value_s = str(value) + prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 + # Suppress ValueError (Could not convert sensor_value to float) + with suppress(ValueError): + temp = units.temperature(float(value), unit_of_measurement) + value = round(temp) if prec == 0 else round(temp, prec) + + return value + + def __repr__(self) -> str: + """Return the representation. + + Entity.__repr__ includes the state in the generated string, this fails if we're + called before self.hass is set. + """ + if not self.hass: + return f"" + + return super().__repr__() diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index a77ed2d2cd7..ffa59271d79 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -16,13 +16,23 @@ from homeassistant.const import ( DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLTAGE, ) from homeassistant.core import HomeAssistant, HomeAssistantError, callback @@ -46,12 +56,22 @@ CONF_IS_CO2 = "is_carbon_dioxide" CONF_IS_CURRENT = "is_current" CONF_IS_ENERGY = "is_energy" CONF_IS_HUMIDITY = "is_humidity" +CONF_IS_GAS = "is_gas" CONF_IS_ILLUMINANCE = "is_illuminance" +CONF_IS_NITROGEN_DIOXIDE = "is_nitrogen_dioxide" +CONF_IS_NITROGEN_MONOXIDE = "is_nitrogen_monoxide" +CONF_IS_NITROUS_OXIDE = "is_nitrous_oxide" +CONF_IS_OZONE = "is_ozone" +CONF_IS_PM1 = "is_pm1" +CONF_IS_PM10 = "is_pm10" +CONF_IS_PM25 = "is_pm25" CONF_IS_POWER = "is_power" CONF_IS_POWER_FACTOR = "is_power_factor" CONF_IS_PRESSURE = "is_pressure" CONF_IS_SIGNAL_STRENGTH = "is_signal_strength" +CONF_IS_SULPHUR_DIOXIDE = "is_sulphur_dioxide" CONF_IS_TEMPERATURE = "is_temperature" +CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VALUE = "is_value" @@ -61,13 +81,25 @@ ENTITY_CONDITIONS = { DEVICE_CLASS_CO2: [{CONF_TYPE: CONF_IS_CO2}], DEVICE_CLASS_CURRENT: [{CONF_TYPE: CONF_IS_CURRENT}], DEVICE_CLASS_ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_IS_GAS}], DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_IS_HUMIDITY}], DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_IS_ILLUMINANCE}], + DEVICE_CLASS_NITROGEN_DIOXIDE: [{CONF_TYPE: CONF_IS_NITROGEN_DIOXIDE}], + DEVICE_CLASS_NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_IS_NITROGEN_MONOXIDE}], + DEVICE_CLASS_NITROUS_OXIDE: [{CONF_TYPE: CONF_IS_NITROUS_OXIDE}], + DEVICE_CLASS_OZONE: [{CONF_TYPE: CONF_IS_OZONE}], DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_IS_POWER}], DEVICE_CLASS_POWER_FACTOR: [{CONF_TYPE: CONF_IS_POWER_FACTOR}], + DEVICE_CLASS_PM1: [{CONF_TYPE: CONF_IS_PM1}], + DEVICE_CLASS_PM10: [{CONF_TYPE: CONF_IS_PM10}], + DEVICE_CLASS_PM25: [{CONF_TYPE: CONF_IS_PM25}], DEVICE_CLASS_PRESSURE: [{CONF_TYPE: CONF_IS_PRESSURE}], DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_IS_SIGNAL_STRENGTH}], + DEVICE_CLASS_SULPHUR_DIOXIDE: [{CONF_TYPE: CONF_IS_SULPHUR_DIOXIDE}], DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_IS_TEMPERATURE}], + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: [ + {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS} + ], DEVICE_CLASS_VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}], } @@ -83,13 +115,23 @@ CONDITION_SCHEMA = vol.All( CONF_IS_CO2, CONF_IS_CURRENT, CONF_IS_ENERGY, + CONF_IS_GAS, CONF_IS_HUMIDITY, CONF_IS_ILLUMINANCE, + CONF_IS_OZONE, + CONF_IS_NITROGEN_DIOXIDE, + CONF_IS_NITROGEN_MONOXIDE, + CONF_IS_NITROUS_OXIDE, CONF_IS_POWER, CONF_IS_POWER_FACTOR, + CONF_IS_PM1, + CONF_IS_PM10, + CONF_IS_PM25, CONF_IS_PRESSURE, CONF_IS_SIGNAL_STRENGTH, + CONF_IS_SULPHUR_DIOXIDE, CONF_IS_TEMPERATURE, + CONF_IS_VOLATILE_ORGANIC_COMPOUNDS, CONF_IS_VOLTAGE, CONF_IS_VALUE, ] diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 3b00bae816d..189b098bea0 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -19,13 +19,23 @@ from homeassistant.const import ( DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_NITROGEN_MONOXIDE, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLTAGE, ) from homeassistant.core import HomeAssistantError @@ -44,13 +54,23 @@ CONF_CO = "carbon_monoxide" CONF_CO2 = "carbon_dioxide" CONF_CURRENT = "current" CONF_ENERGY = "energy" +CONF_GAS = "gas" CONF_HUMIDITY = "humidity" CONF_ILLUMINANCE = "illuminance" +CONF_NITROGEN_DIOXIDE = "nitrogen_dioxide" +CONF_NITROGEN_MONOXIDE = "nitrogen_monoxide" +CONF_NITROUS_OXIDE = "nitrous_oxide" +CONF_OZONE = "ozone" +CONF_PM1 = "pm1" +CONF_PM10 = "pm10" +CONF_PM25 = "pm25" CONF_POWER = "power" CONF_POWER_FACTOR = "power_factor" CONF_PRESSURE = "pressure" CONF_SIGNAL_STRENGTH = "signal_strength" +CONF_SULPHUR_DIOXIDE = "sulphur_dioxide" CONF_TEMPERATURE = "temperature" +CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" CONF_VOLTAGE = "voltage" CONF_VALUE = "value" @@ -60,13 +80,25 @@ ENTITY_TRIGGERS = { DEVICE_CLASS_CO2: [{CONF_TYPE: CONF_CO2}], DEVICE_CLASS_CURRENT: [{CONF_TYPE: CONF_CURRENT}], DEVICE_CLASS_ENERGY: [{CONF_TYPE: CONF_ENERGY}], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_GAS}], DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}], DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_ILLUMINANCE}], + DEVICE_CLASS_NITROGEN_DIOXIDE: [{CONF_TYPE: CONF_NITROGEN_DIOXIDE}], + DEVICE_CLASS_NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_NITROGEN_MONOXIDE}], + DEVICE_CLASS_NITROUS_OXIDE: [{CONF_TYPE: CONF_NITROUS_OXIDE}], + DEVICE_CLASS_OZONE: [{CONF_TYPE: CONF_OZONE}], + DEVICE_CLASS_PM1: [{CONF_TYPE: CONF_PM1}], + DEVICE_CLASS_PM10: [{CONF_TYPE: CONF_PM10}], + DEVICE_CLASS_PM25: [{CONF_TYPE: CONF_PM25}], DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWER}], DEVICE_CLASS_POWER_FACTOR: [{CONF_TYPE: CONF_POWER_FACTOR}], DEVICE_CLASS_PRESSURE: [{CONF_TYPE: CONF_PRESSURE}], DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_SIGNAL_STRENGTH}], + DEVICE_CLASS_SULPHUR_DIOXIDE: [{CONF_TYPE: CONF_SULPHUR_DIOXIDE}], DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_TEMPERATURE}], + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: [ + {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS} + ], DEVICE_CLASS_VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}], } @@ -83,13 +115,23 @@ TRIGGER_SCHEMA = vol.All( CONF_CO2, CONF_CURRENT, CONF_ENERGY, + CONF_GAS, CONF_HUMIDITY, CONF_ILLUMINANCE, + CONF_NITROGEN_DIOXIDE, + CONF_NITROGEN_MONOXIDE, + CONF_NITROUS_OXIDE, + CONF_OZONE, + CONF_PM1, + CONF_PM10, + CONF_PM25, CONF_POWER, CONF_POWER_FACTOR, CONF_PRESSURE, CONF_SIGNAL_STRENGTH, + CONF_SULPHUR_DIOXIDE, CONF_TEMPERATURE, + CONF_VOLATILE_ORGANIC_COMPOUNDS, CONF_VOLTAGE, CONF_VALUE, ] diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index afcfe2f228d..0054b01abd2 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -9,13 +9,14 @@ from typing import Callable from homeassistant.components.recorder import history, statistics from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_GAS, DEVICE_CLASS_MONETARY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + STATE_CLASSES, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -23,7 +24,6 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, - PERCENTAGE, POWER_KILO_WATT, POWER_WATT, PRESSURE_BAR, @@ -35,25 +35,32 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity import entity_sources import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util +import homeassistant.util.volume as volume_util from . import ATTR_LAST_RESET, DOMAIN _LOGGER = logging.getLogger(__name__) -DEVICE_CLASS_OR_UNIT_STATISTICS = { - DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, - DEVICE_CLASS_ENERGY: {"sum"}, - DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"}, - DEVICE_CLASS_MONETARY: {"sum"}, - DEVICE_CLASS_POWER: {"mean", "min", "max"}, - DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, - DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, - PERCENTAGE: {"mean", "min", "max"}, +DEVICE_CLASS_STATISTICS: dict[str, dict[str, set[str]]] = { + STATE_CLASS_MEASUREMENT: { + # Deprecated, support will be removed in Home Assistant 2021.11 + DEVICE_CLASS_ENERGY: {"sum"}, + DEVICE_CLASS_GAS: {"sum"}, + DEVICE_CLASS_MONETARY: {"sum"}, + }, + STATE_CLASS_TOTAL_INCREASING: {}, +} +DEFAULT_STATISTICS = { + STATE_CLASS_MEASUREMENT: {"mean", "min", "max"}, + STATE_CLASS_TOTAL_INCREASING: {"sum"}, } # Normalized units which will be stored in the statistics table @@ -62,6 +69,7 @@ DEVICE_CLASS_UNITS = { DEVICE_CLASS_POWER: POWER_WATT, DEVICE_CLASS_PRESSURE: PRESSURE_PA, DEVICE_CLASS_TEMPERATURE: TEMP_CELSIUS, + DEVICE_CLASS_GAS: VOLUME_CUBIC_METERS, } UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { @@ -92,30 +100,31 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { TEMP_FAHRENHEIT: temperature_util.fahrenheit_to_celsius, TEMP_KELVIN: temperature_util.kelvin_to_celsius, }, + # Convert volume to cubic meter + DEVICE_CLASS_GAS: { + VOLUME_CUBIC_METERS: lambda x: x, + VOLUME_CUBIC_FEET: volume_util.cubic_feet_to_cubic_meter, + }, } +# 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 unsupported unit has been logged -WARN_UNSUPPORTED_UNIT = set() +WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" +WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" -def _get_entities(hass: HomeAssistant) -> list[tuple[str, str]]: - """Get (entity_id, device_class) of all sensors for which to compile statistics.""" +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.""" all_sensors = hass.states.all(DOMAIN) entity_ids = [] for state in all_sensors: - if state.attributes.get(ATTR_STATE_CLASS) != STATE_CLASS_MEASUREMENT: + if (state_class := state.attributes.get(ATTR_STATE_CLASS)) not in STATE_CLASSES: continue - - if ( - key := state.attributes.get(ATTR_DEVICE_CLASS) - ) in DEVICE_CLASS_OR_UNIT_STATISTICS: - entity_ids.append((state.entity_id, key)) - - if ( - key := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ) in DEVICE_CLASS_OR_UNIT_STATISTICS: - entity_ids.append((state.entity_id, key)) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + entity_ids.append((state.entity_id, state_class, device_class)) return entity_ids @@ -165,18 +174,46 @@ def _time_weighted_average( return accumulated / (end - start).total_seconds() +def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: + """Return True if all states have the same unit.""" + return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} + + def _normalize_states( - entity_history: list[State], key: str, entity_id: str + hass: HomeAssistant, + entity_history: list[State], + device_class: str | None, + entity_id: str, ) -> tuple[str | None, list[tuple[float, State]]]: """Normalize units.""" unit = None - if key not in UNIT_CONVERSIONS: + if device_class not in UNIT_CONVERSIONS: # We're not normalizing this device class, return the state as they are fstates = [ (float(el.state), el) for el in entity_history if _is_number(el.state) ] if fstates: + all_units = _get_units(fstates) + if len(all_units) > 1: + if WARN_UNSTABLE_UNIT not in hass.data: + hass.data[WARN_UNSTABLE_UNIT] = set() + 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): + extra = ( + " and matches the unit of already compiled statistics " + f"({old_metadata['unit_of_measurement']})" + ) + _LOGGER.warning( + "The unit of %s is changing, got multiple %s, generation of long term " + "statistics will be suppressed unless the unit is stable%s", + entity_id, + all_units, + extra, + ) + return None, [] unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) return unit, fstates @@ -190,18 +227,77 @@ def _normalize_states( fstate = float(state.state) unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[key]: - if entity_id not in WARN_UNSUPPORTED_UNIT: - WARN_UNSUPPORTED_UNIT.add(entity_id) + if unit not in UNIT_CONVERSIONS[device_class]: + if WARN_UNSUPPORTED_UNIT not in hass.data: + hass.data[WARN_UNSUPPORTED_UNIT] = set() + if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: + hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) _LOGGER.warning("%s has unknown unit %s", entity_id, unit) continue - fstates.append((UNIT_CONVERSIONS[key][unit](fstate), state)) + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) - return DEVICE_CLASS_UNITS[key], fstates + return DEVICE_CLASS_UNITS[device_class], fstates -def compile_statistics( +def warn_dip(hass: HomeAssistant, entity_id: str) -> None: + """Log a warning once if a sensor with state_class_total has a decreasing value. + + The log will be suppressed until two dips have been seen to prevent warning due to + rounding issues with databases storing the state as a single precision float, which + was fixed in recorder DB version 20. + """ + if SEEN_DIP not in hass.data: + hass.data[SEEN_DIP] = set() + if entity_id not in hass.data[SEEN_DIP]: + hass.data[SEEN_DIP].add(entity_id) + return + if WARN_DIP not in hass.data: + hass.data[WARN_DIP] = set() + if entity_id not in hass.data[WARN_DIP]: + hass.data[WARN_DIP].add(entity_id) + domain = entity_sources(hass).get(entity_id, {}).get("domain") + if domain in ["energy", "growatt_server", "solaredge"]: + return + _LOGGER.warning( + "Entity %s %shas state class total_increasing, but its state is " + "not strictly increasing. Please create a bug report at %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", + ) + + +def reset_detected( + hass: HomeAssistant, entity_id: str, state: float, previous_state: float | None +) -> bool: + """Test if a total_increasing sensor has been reset.""" + if previous_state is None: + return False + + if 0.9 * previous_state <= state < previous_state: + warn_dip(hass, entity_id) + + return state < 0.9 * previous_state + + +def _wanted_statistics( + entities: list[tuple[str, str, str | None]] +) -> dict[str, set[str]]: + """Prepare a dict with wanted statistics for entities.""" + wanted_statistics = {} + for entity_id, state_class, device_class in entities: + if device_class in DEVICE_CLASS_STATISTICS[state_class]: + wanted_statistics[entity_id] = DEVICE_CLASS_STATISTICS[state_class][ + device_class + ] + else: + wanted_statistics[entity_id] = DEFAULT_STATISTICS[state_class] + return wanted_statistics + + +def compile_statistics( # noqa: C901 hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> dict: """Compile statistics for all entities during start-end. @@ -212,43 +308,79 @@ def compile_statistics( entities = _get_entities(hass) + wanted_statistics = _wanted_statistics(entities) + # Get history between start and end - history_list = history.get_significant_states( # type: ignore - hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities] - ) - - for entity_id, key in entities: - wanted_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[key] + entities_full_history = [i[0] for i in entities if "sum" in wanted_statistics[i[0]]] + history_list = {} + if entities_full_history: + history_list = history.get_significant_states( # type: ignore + hass, + 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]] + ] + if entities_significant_history: + _history_list = history.get_significant_states( # type: ignore + hass, + start - datetime.timedelta.resolution, + end, + entity_ids=entities_significant_history, + ) + history_list = {**history_list, **_history_list} + for entity_id, state_class, device_class in entities: if entity_id not in history_list: continue entity_history = history_list[entity_id] - unit, fstates = _normalize_states(entity_history, key, entity_id) + unit, fstates = _normalize_states(hass, 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 WARN_UNSTABLE_UNIT not in hass.data: + hass.data[WARN_UNSTABLE_UNIT] = set() + if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: + hass.data[WARN_UNSTABLE_UNIT].add(entity_id) + _LOGGER.warning( + "The unit of %s (%s) does not match the unit of already " + "compiled statistics (%s). Generation of long term statistics " + "will be suppressed unless the unit changes back to %s", + entity_id, + unit, + old_metadata["unit_of_measurement"], + old_metadata["unit_of_measurement"], + ) + continue + result[entity_id] = {} # Set meta data result[entity_id]["meta"] = { "unit_of_measurement": unit, - "has_mean": "mean" in wanted_statistics, - "has_sum": "sum" in wanted_statistics, + "has_mean": "mean" in wanted_statistics[entity_id], + "has_sum": "sum" in wanted_statistics[entity_id], } # Make calculations stat: dict = {} - if "max" in wanted_statistics: + if "max" in wanted_statistics[entity_id]: stat["max"] = max(*itertools.islice(zip(*fstates), 1)) - if "min" in wanted_statistics: + if "min" in wanted_statistics[entity_id]: stat["min"] = min(*itertools.islice(zip(*fstates), 1)) - if "mean" in wanted_statistics: + if "mean" in wanted_statistics[entity_id]: stat["mean"] = _time_weighted_average(fstates, start, end) - if "sum" in wanted_statistics: + if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None new_state = old_state = None _sum = 0 @@ -257,31 +389,86 @@ def compile_statistics( # 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"] + _sum = last_stats[entity_id][0]["sum"] or 0 for fstate, state in fstates: - if "last_reset" not in state.attributes: + # Deprecated, will be removed in Home Assistant 2021.10 + if ( + "last_reset" not in state.attributes + and state_class == STATE_CLASS_MEASUREMENT + ): continue - if (last_reset := state.attributes["last_reset"]) != old_last_reset: + + reset = False + if ( + state_class != STATE_CLASS_TOTAL_INCREASING + and (last_reset := state.attributes.get("last_reset")) + != old_last_reset + ): + if old_state is None: + _LOGGER.info( + "Compiling initial sum statistics for %s, zero point set to %s", + entity_id, + fstate, + ) + else: + _LOGGER.info( + "Detected new cycle for %s, last_reset set to %s (old last_reset %s)", + entity_id, + last_reset, + old_last_reset, + ) + reset = True + elif old_state is None and last_reset is None: + reset = True + _LOGGER.info( + "Compiling initial sum statistics for %s, zero point set to %s", + 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, + fstate, + new_state, + ) + + if reset: # The sensor has been reset, update the sum if old_state is not None: _sum += new_state - old_state # ..and update the starting point new_state = fstate old_last_reset = last_reset - old_state = new_state + # Force a new cycle for an existing sensor to start at 0 + if old_state is not None: + old_state = 0.0 + else: + old_state = new_state else: new_state = fstate - if last_reset is None or new_state is None or old_state is None: + # 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 _sum += new_state - old_state - stat["last_reset"] = dt_util.parse_datetime(last_reset) + if last_reset is not None: + stat["last_reset"] = dt_util.parse_datetime(last_reset) stat["sum"] = _sum stat["state"] = new_state @@ -296,8 +483,11 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - statistic_ids = {} - for entity_id, key in entities: - provided_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[key] + for entity_id, state_class, device_class in entities: + if device_class in DEVICE_CLASS_STATISTICS[state_class]: + provided_statistics = DEVICE_CLASS_STATISTICS[state_class][device_class] + else: + provided_statistics = DEFAULT_STATISTICS[state_class] if statistic_type is not None and statistic_type not in provided_statistics: continue @@ -305,19 +495,27 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - state = hass.states.get(entity_id) assert state - if "sum" in provided_statistics and ATTR_LAST_RESET not in state.attributes: + if ( + "sum" in provided_statistics + and ATTR_LAST_RESET not in state.attributes + and state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + ): continue - native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + 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 key not in UNIT_CONVERSIONS: + if device_class not in UNIT_CONVERSIONS: statistic_ids[entity_id] = native_unit continue - if native_unit not in UNIT_CONVERSIONS[key]: + if native_unit not in UNIT_CONVERSIONS[device_class]: continue - statistics_unit = DEVICE_CLASS_UNITS[key] + statistics_unit = DEVICE_CLASS_UNITS[device_class] statistic_ids[entity_id] = statistics_unit return statistic_ids diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index efe5366cfec..1dec2b60e20 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -5,15 +5,25 @@ "is_battery_level": "Current {entity_name} battery level", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_carbon_dioxide": "Current {entity_name} carbon dioxide concentration level", + "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", "is_illuminance": "Current {entity_name} illuminance", + "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", + "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", + "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", + "is_ozone": "Current {entity_name} ozone concentration level", + "is_pm1": "Current {entity_name} PM1 concentration level", + "is_pm10": "Current {entity_name} PM10 concentration level", + "is_pm25": "Current {entity_name} PM2.5 concentration level", "is_power": "Current {entity_name} power", "is_pressure": "Current {entity_name} pressure", "is_signal_strength": "Current {entity_name} signal strength", + "is_sulphur_dioxide": "Current {entity_name} sulphur dioxide concentration level", "is_temperature": "Current {entity_name} temperature", "is_current": "Current {entity_name} current", "is_energy": "Current {entity_name} energy", "is_power_factor": "Current {entity_name} power factor", + "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage", "is_value": "Current {entity_name} value" }, @@ -21,15 +31,25 @@ "battery_level": "{entity_name} battery level changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", + "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", "illuminance": "{entity_name} illuminance changes", + "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", + "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", + "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", + "ozone": "{entity_name} ozone concentration changes", + "pm1": "{entity_name} PM1 concentration changes", + "pm10": "{entity_name} PM10 concentration changes", + "pm25": "{entity_name} PM2.5 concentration changes", "power": "{entity_name} power changes", "pressure": "{entity_name} pressure changes", "signal_strength": "{entity_name} signal strength changes", + "sulphur_dioxide": "{entity_name} sulphur dioxide concentration changes", "temperature": "{entity_name} temperature changes", "current": "{entity_name} current changes", "energy": "{entity_name} energy changes", "power_factor": "{entity_name} power factor changes", + "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes", "value": "{entity_name} value changes" } diff --git a/homeassistant/components/sensor/translations/ca.json b/homeassistant/components/sensor/translations/ca.json index a30748d5c9c..c90ed273a67 100644 --- a/homeassistant/components/sensor/translations/ca.json +++ b/homeassistant/components/sensor/translations/ca.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "Concentraci\u00f3 actual de mon\u00f2xid de carboni de {entity_name}", "is_current": "Intensitat actual de {entity_name}", "is_energy": "Energia actual de {entity_name}", + "is_gas": "Gas actual de {entity_name}", "is_humidity": "Humitat actual de {entity_name}", "is_illuminance": "Il\u00b7luminaci\u00f3 actual de {entity_name}", + "is_nitrogen_dioxide": "Concentraci\u00f3 actual de di\u00f2xid de nitrogen de {entity_name}", + "is_nitrogen_monoxide": "Concentraci\u00f3 actual de mon\u00f2xid de nitrogen de {entity_name}", + "is_nitrous_oxide": "Concentraci\u00f3 actual d'\u00f2xid nitr\u00f3s de {entity_name}", + "is_ozone": "Concentraci\u00f3 actual d'oz\u00f3 de {entity_name}", + "is_pm1": "Concentraci\u00f3 actual de PM1 de {entity_name}", + "is_pm10": "Concentraci\u00f3 actual de PM10 de {entity_name}", + "is_pm25": "Concentraci\u00f3 actual de PM2.5 de {entity_name}", "is_power": "Pot\u00e8ncia actual de {entity_name}", "is_power_factor": "Factor de pot\u00e8ncia actual de {entity_name}", "is_pressure": "Pressi\u00f3 actual de {entity_name}", "is_signal_strength": "Pot\u00e8ncia de senyal actual de {entity_name}", + "is_sulphur_dioxide": "Concentraci\u00f3 actual de di\u00f2xid de sofre de {entity_name}", "is_temperature": "Temperatura actual de {entity_name}", "is_value": "Valor actual de {entity_name}", + "is_volatile_organic_compounds": "Concentraci\u00f3 actual de compostos org\u00e0nics vol\u00e0tils de {entity_name}", "is_voltage": "Voltatge actual de {entity_name}" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "Canvia la concentraci\u00f3 de mon\u00f2xid de carboni de {entity_name}", "current": "Canvia la intensitat de {entity_name}", "energy": "Canvia l'energia de {entity_name}", + "gas": "Canvia el gas de {entity_name}", "humidity": "Canvia la humitat de {entity_name}", "illuminance": "Canvia la il\u00b7luminaci\u00f3 de {entity_name}", + "nitrogen_dioxide": "Canvia la concentraci\u00f3 de di\u00f2xid de nitrogen de {entity_name}", + "nitrogen_monoxide": "Canvia la concentraci\u00f3 de mon\u00f2xid de nitrogen de {entity_name}", + "nitrous_oxide": "Canvia la concentraci\u00f3 d'\u00f2xid nitr\u00f3s de {entity_name}", + "ozone": "Canvia la concentraci\u00f3 d'oz\u00f3 de {entity_name}", + "pm1": "Canvia la concentraci\u00f3 de PM1 de {entity_name}", + "pm10": "Canvia la concentraci\u00f3 de PM10 de {entity_name}", + "pm25": "Canvia la concentraci\u00f3 de PM2.5 de {entity_name}", "power": "Canvia la pot\u00e8ncia de {entity_name}", "power_factor": "Canvia el factor de pot\u00e8ncia de {entity_name}", "pressure": "Canvia la pressi\u00f3 de {entity_name}", "signal_strength": "Canvia la pot\u00e8ncia de senyal de {entity_name}", + "sulphur_dioxide": "Canvia la concentraci\u00f3 de di\u00f2xid de sofre de {entity_name}", "temperature": "Canvia la temperatura de {entity_name}", "value": "Canvia el valor de {entity_name}", + "volatile_organic_compounds": "Canvia la concentraci\u00f3 de compostos org\u00e0nics vol\u00e0tils de {entity_name}", "voltage": "Canvia el voltatge de {entity_name}" } }, diff --git a/homeassistant/components/sensor/translations/cs.json b/homeassistant/components/sensor/translations/cs.json index dfa2a263783..f493f4134ed 100644 --- a/homeassistant/components/sensor/translations/cs.json +++ b/homeassistant/components/sensor/translations/cs.json @@ -4,6 +4,7 @@ "is_battery_level": "Aktu\u00e1ln\u00ed \u00farove\u0148 nabit\u00ed baterie {entity_name}", "is_current": "Aktu\u00e1ln\u00ed proud {entity_name}", "is_energy": "Aktu\u00e1ln\u00ed energie {entity_name}", + "is_gas": "Aktu\u00e1ln\u00ed mno\u017estv\u00ed plynu {entity_name}", "is_humidity": "Aktu\u00e1ln\u00ed vlhkost {entity_name}", "is_illuminance": "Aktu\u00e1ln\u00ed osv\u011btlen\u00ed {entity_name}", "is_power": "Aktu\u00e1ln\u00ed v\u00fdkon {entity_name}", @@ -18,6 +19,7 @@ "battery_level": "P\u0159i zm\u011bn\u011b \u00farovn\u011b baterie {entity_name}", "current": "P\u0159i zm\u011bn\u011b proudu {entity_name}", "energy": "P\u0159i zm\u011bn\u011b energie {entity_name}", + "gas": "P\u0159i zm\u011bn\u011b mno\u017estv\u00ed plynu {entity_name}", "humidity": "P\u0159i zm\u011bn\u011b vlhkosti {entity_name}", "illuminance": "P\u0159i zm\u011bn\u011b osv\u011btlen\u00ed {entity_name}", "power": "P\u0159i zm\u011bn\u011b el. v\u00fdkonu {entity_name}", diff --git a/homeassistant/components/sensor/translations/de.json b/homeassistant/components/sensor/translations/de.json index 4f16c07be01..1b041b576fc 100644 --- a/homeassistant/components/sensor/translations/de.json +++ b/homeassistant/components/sensor/translations/de.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "Aktuelle {entity_name} Kohlenstoffmonoxid-Konzentration", "is_current": "Aktueller Strom von {entity_name}", "is_energy": "Aktuelle Energie von {entity_name}", + "is_gas": "Aktuelles {entity_name} Gas", "is_humidity": "{entity_name} Feuchtigkeit", "is_illuminance": "Aktuelle {entity_name} Helligkeit", + "is_nitrogen_dioxide": "Aktuelle Stickstoffdioxid-Konzentration von {entity_name}", + "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_pm25": "Aktuelle PM2.5-Konzentration von {entity_name}", "is_power": "Aktuelle {entity_name} Leistung", "is_power_factor": "Aktueller Leistungsfaktor f\u00fcr {entity_name}", "is_pressure": "{entity_name} Druck", "is_signal_strength": "Aktuelle {entity_name} Signalst\u00e4rke", + "is_sulphur_dioxide": "Aktuelle Schwefeldioxid-Konzentration von {entity_name}", "is_temperature": "Aktuelle {entity_name} Temperatur", "is_value": "Aktueller {entity_name} Wert", + "is_volatile_organic_compounds": "Aktuelle Konzentration fl\u00fcchtiger organischer Verbindungen in {entity_name}", "is_voltage": "Aktuelle Spannung von {entity_name}" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "{entity_name} Kohlenstoffmonoxid-Konzentrations\u00e4nderung", "current": "{entity_name} Stromver\u00e4nderung", "energy": "{entity_name} Energie\u00e4nderungen", + "gas": "{entity_name} Gas\u00e4nderungen", "humidity": "{entity_name} Feuchtigkeits\u00e4nderungen", "illuminance": "{entity_name} Helligkeits\u00e4nderungen", + "nitrogen_dioxide": "\u00c4nderung der Stickstoffdioxidkonzentration bei {entity_name}", + "nitrogen_monoxide": "\u00c4nderung der Stickstoffmonoxid-Konzentration bei {entity_name}", + "nitrous_oxide": "\u00c4nderung der Lachgaskonzentration bei {entity_name}", + "ozone": "\u00c4nderung der Ozonkonzentration bei {entity_name}", + "pm1": "\u00c4nderung der PM1-Konzentration bei {entity_name}", + "pm10": "\u00c4nderung der PM10-Konzentration bei {entity_name}", + "pm25": "\u00c4nderung der PM2,5-Konzentration bei {entity_name}", "power": "{entity_name} Leistungs\u00e4nderungen", "power_factor": "{entity_name} Leistungsfaktor\u00e4nderung", "pressure": "{entity_name} Druck\u00e4nderungen", "signal_strength": "{entity_name} Signalst\u00e4rke\u00e4nderungen", + "sulphur_dioxide": "\u00c4nderung der Schwefeldioxidkonzentration bei {entity_name}", "temperature": "{entity_name} Temperatur\u00e4nderungen", "value": "{entity_name} Wert\u00e4nderungen", + "volatile_organic_compounds": "{entity_name} Konzentrations\u00e4nderungen fl\u00fcchtiger organischer Verbindungen", "voltage": "{entity_name} Spannungs\u00e4nderungen" } }, diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json index f8f45f93309..b5cb2f5a27f 100644 --- a/homeassistant/components/sensor/translations/en.json +++ b/homeassistant/components/sensor/translations/en.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_current": "Current {entity_name} current", "is_energy": "Current {entity_name} energy", + "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", "is_illuminance": "Current {entity_name} illuminance", + "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", + "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", + "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", + "is_ozone": "Current {entity_name} ozone concentration level", + "is_pm1": "Current {entity_name} PM1 concentration level", + "is_pm10": "Current {entity_name} PM10 concentration level", + "is_pm25": "Current {entity_name} PM2.5 concentration level", "is_power": "Current {entity_name} power", "is_power_factor": "Current {entity_name} power factor", "is_pressure": "Current {entity_name} pressure", "is_signal_strength": "Current {entity_name} signal strength", + "is_sulphur_dioxide": "Current {entity_name} sulphur dioxide concentration level", "is_temperature": "Current {entity_name} temperature", "is_value": "Current {entity_name} value", + "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "current": "{entity_name} current changes", "energy": "{entity_name} energy changes", + "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", "illuminance": "{entity_name} illuminance changes", + "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", + "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", + "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", + "ozone": "{entity_name} ozone concentration changes", + "pm1": "{entity_name} PM1 concentration changes", + "pm10": "{entity_name} PM10 concentration changes", + "pm25": "{entity_name} PM2.5 concentration changes", "power": "{entity_name} power changes", "power_factor": "{entity_name} power factor changes", "pressure": "{entity_name} pressure changes", "signal_strength": "{entity_name} signal strength changes", + "sulphur_dioxide": "{entity_name} sulphur dioxide concentration changes", "temperature": "{entity_name} temperature changes", "value": "{entity_name} value changes", + "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes" } }, diff --git a/homeassistant/components/sensor/translations/es.json b/homeassistant/components/sensor/translations/es.json index da96c7d92db..48c61f321a1 100644 --- a/homeassistant/components/sensor/translations/es.json +++ b/homeassistant/components/sensor/translations/es.json @@ -6,6 +6,7 @@ "is_carbon_monoxide": "Nivel actual de concentraci\u00f3n de mon\u00f3xido de carbono {entity_name}", "is_current": "Corriente actual de {entity_name}", "is_energy": "Energ\u00eda actual de {entity_name}", + "is_gas": "Gas actual de {entity_name}", "is_humidity": "Humedad actual de {entity_name}", "is_illuminance": "Luminosidad actual de {entity_name}", "is_power": "Potencia actual de {entity_name}", @@ -22,6 +23,7 @@ "carbon_monoxide": "{entity_name} cambios en la concentraci\u00f3n de mon\u00f3xido de carbono", "current": "Cambio de corriente en {entity_name}", "energy": "Cambio de energ\u00eda en {entity_name}", + "gas": "Cambio de gas de {entity_name}", "humidity": "Cambios de humedad de {entity_name}", "illuminance": "Cambios de luminosidad de {entity_name}", "power": "Cambios de potencia de {entity_name}", diff --git a/homeassistant/components/sensor/translations/et.json b/homeassistant/components/sensor/translations/et.json index 4169e7b82db..5cfa6a94852 100644 --- a/homeassistant/components/sensor/translations/et.json +++ b/homeassistant/components/sensor/translations/et.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "{entity_name} praegune vingugaasi tase", "is_current": "Praegune {entity_name} voolutugevus", "is_energy": "Praegune {entity_name} v\u00f5imsus", + "is_gas": "Praegune {entity_name} gaas", "is_humidity": "Praegune {entity_name} niiskus", "is_illuminance": "Praegune {entity_name} valgustatus", + "is_nitrogen_dioxide": "Praegune {entity_name} l\u00e4mmastikdioksiidi kontsentratsioonitase", + "is_nitrogen_monoxide": "Praegune {entity_name} l\u00e4mmastikmonooksiidi kontsentratsioonitase", + "is_nitrous_oxide": "Praegune {entity_name} dil\u00e4mmastikoksiidi kontsentratsioonitase", + "is_ozone": "Praegune osoonisisalduse tase {entity_name}", + "is_pm1": "Praegune {entity_name} PM1 kontsentratsioonitase", + "is_pm10": "Praegune {entity_name} PM10 kontsentratsioonitase", + "is_pm25": "Praegune {entity_name} PM2.5 kontsentratsioonitase", "is_power": "Praegune {entity_name} toide (v\u00f5imsus)", "is_power_factor": "Praegune {entity_name} v\u00f5imsusfaktor", "is_pressure": "Praegune {entity_name} r\u00f5hk", "is_signal_strength": "Praegune {entity_name} signaali tugevus", + "is_sulphur_dioxide": "Praegune v\u00e4\u00e4veldioksiidi kontsentratsioonitase {entity_name}", "is_temperature": "Praegune {entity_name} temperatuur", "is_value": "Praegune {entity_name} v\u00e4\u00e4rtus", + "is_volatile_organic_compounds": "Praegune {entity_name} lenduvate orgaaniliste \u00fchendite kontsentratsioonitase", "is_voltage": "Praegune {entity_name}pinge" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "{entity_name} vingugaasi tase muutus", "current": "{entity_name} voolutugevus muutub", "energy": "{entity_name} v\u00f5imsus muutub", + "gas": "{entity_name} gaasivahetus", "humidity": "{entity_name} niiskus muutub", "illuminance": "{entity_name} valgustustugevus muutub", + "nitrogen_dioxide": "{entity_name} l\u00e4mmastikdioksiidi kontsentratsiooni muutused", + "nitrogen_monoxide": "{entity_name} l\u00e4mmastikmonooksiidi kontsentratsiooni muutused", + "nitrous_oxide": "{entity_name} l\u00e4mmastikoksiidi kontsentratsiooni muutused", + "ozone": "{entity_name} osooni kontsentratsiooni muutused", + "pm1": "{entity_name} PM1 kontsentratsiooni muutused", + "pm10": "{entity_name} PM10 kontsentratsiooni muutused", + "pm25": "{entity_name} PM2.5 kontsentratsiooni muutused", "power": "{entity_name} energiare\u017eiimi muutub", "power_factor": "{entity_name} v\u00f5imsus muutub", "pressure": "{entity_name} r\u00f5hk muutub", "signal_strength": "{entity_name} signaalitugevus muutub", + "sulphur_dioxide": "{entity_name} v\u00e4\u00e4veldioksiidi kontsentratsiooni muutused", "temperature": "{entity_name} temperatuur muutub", "value": "{entity_name} v\u00e4\u00e4rtus muutub", + "volatile_organic_compounds": "{entity_name} lenduvate orgaaniliste \u00fchendite kontsentratsiooni muutused", "voltage": "{entity_name} pingemuutub" } }, diff --git a/homeassistant/components/sensor/translations/hu.json b/homeassistant/components/sensor/translations/hu.json index 9b1c9bece82..58ecdea0f24 100644 --- a/homeassistant/components/sensor/translations/hu.json +++ b/homeassistant/components/sensor/translations/hu.json @@ -6,12 +6,21 @@ "is_carbon_monoxide": "Jelenlegi {entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3 szint", "is_current": "Jelenlegi {entity_name} \u00e1ram", "is_energy": "A jelenlegi {entity_name} energia", + "is_gas": "Jelenlegi {entity_name} g\u00e1z", "is_humidity": "{entity_name} aktu\u00e1lis p\u00e1ratartalma", "is_illuminance": "{entity_name} aktu\u00e1lis megvil\u00e1g\u00edt\u00e1sa", + "is_nitrogen_dioxide": "Jelenlegi {entity_name} nitrog\u00e9n-dioxid-koncentr\u00e1ci\u00f3 szint", + "is_nitrogen_monoxide": "Jelenlegi {entity_name} nitrog\u00e9n-monoxid-koncentr\u00e1ci\u00f3 szint", + "is_nitrous_oxide": "Jelenlegi {entity_name} dinitrog\u00e9n-oxid-koncentr\u00e1ci\u00f3 szint", + "is_ozone": "Jelenlegi {entity_name} \u00f3zonkoncentr\u00e1ci\u00f3 szint", + "is_pm1": "Jelenlegi {entity_name} PM1 koncentr\u00e1ci\u00f3 szintje", + "is_pm10": "Jelenlegi {entity_name} PM10 koncentr\u00e1ci\u00f3 szintje", + "is_pm25": "Jelenlegi {entity_name} PM2.5 koncentr\u00e1ci\u00f3 szintje", "is_power": "{entity_name} aktu\u00e1lis teljes\u00edtm\u00e9nye", "is_power_factor": "A jelenlegi {entity_name} teljes\u00edtm\u00e9nyt\u00e9nyez\u0151", "is_pressure": "{entity_name} aktu\u00e1lis nyom\u00e1sa", "is_signal_strength": "{entity_name} aktu\u00e1lis jeler\u0151ss\u00e9ge", + "is_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_voltage": "A jelenlegi {entity_name} fesz\u00fclts\u00e9g" @@ -22,12 +31,21 @@ "carbon_monoxide": "{entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3ja megv\u00e1ltozik", "current": "{entity_name} aktu\u00e1lis v\u00e1ltoz\u00e1sai", "energy": "{entity_name} energiav\u00e1ltoz\u00e1sa", + "gas": "{entity_name} g\u00e1z v\u00e1ltoz\u00e1sok", "humidity": "{entity_name} p\u00e1ratartalma v\u00e1ltozik", "illuminance": "{entity_name} megvil\u00e1g\u00edt\u00e1sa v\u00e1ltozik", + "nitrogen_dioxide": "{entity_name} nitrog\u00e9n-dioxid koncentr\u00e1ci\u00f3 v\u00e1ltozik", + "nitrogen_monoxide": "{entity_name} nitrog\u00e9n-monoxid koncentr\u00e1ci\u00f3 v\u00e1ltozik", + "nitrous_oxide": "{entity_name} dinitrog\u00e9n-oxid koncentr\u00e1ci\u00f3ja v\u00e1ltozik", + "ozone": "{entity_name} \u00f3zonkoncentr\u00e1ci\u00f3 v\u00e1ltozik", + "pm1": "{entity_name} PM1 koncentr\u00e1ci\u00f3 v\u00e1ltozik", + "pm10": "{entity_name} PM10 koncentr\u00e1ci\u00f3 v\u00e1ltozik", + "pm25": "{entity_name} PM2.5 koncentr\u00e1ci\u00f3 v\u00e1ltozik", "power": "{entity_name} teljes\u00edtm\u00e9nye v\u00e1ltozik", "power_factor": "{entity_name} teljes\u00edtm\u00e9nyt\u00e9nyez\u0151 megv\u00e1ltozik", "pressure": "{entity_name} nyom\u00e1sa v\u00e1ltozik", "signal_strength": "{entity_name} jeler\u0151ss\u00e9ge v\u00e1ltozik", + "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", "voltage": "{entity_name} fesz\u00fclts\u00e9ge v\u00e1ltozik" diff --git a/homeassistant/components/sensor/translations/it.json b/homeassistant/components/sensor/translations/it.json index 6ae19c201d7..7b9b483c024 100644 --- a/homeassistant/components/sensor/translations/it.json +++ b/homeassistant/components/sensor/translations/it.json @@ -6,12 +6,21 @@ "is_carbon_monoxide": "Livello attuale di concentrazione di monossido di carbonio in {entity_name}", "is_current": "Corrente attuale di {entity_name}", "is_energy": "Energia attuale di {entity_name}", + "is_gas": "Attuale gas di {entity_name}", "is_humidity": "Umidit\u00e0 attuale di {entity_name}", "is_illuminance": "Illuminazione attuale di {entity_name}", + "is_nitrogen_dioxide": "Attuale livello di concentrazione di biossido di azoto di {entity_name}", + "is_nitrogen_monoxide": "Attuale livello di concentrazione di monossido di azoto di {entity_name}", + "is_nitrous_oxide": "Attuale livello di concentrazione di ossidi di azoto di {entity_name}", + "is_ozone": "Attuale livello di concentrazione di ozono di {entity_name}", + "is_pm1": "Attuale livello di concentrazione di PM1 di {entity_name}", + "is_pm10": "Attuale livello di concentrazione di PM10 di {entity_name}", + "is_pm25": "Attuale livello di concentrazione di PM2.5 di {entity_name}", "is_power": "Alimentazione attuale di {entity_name}", "is_power_factor": "Fattore di potenza attuale di {entity_name}", "is_pressure": "Pressione attuale di {entity_name}", "is_signal_strength": "Potenza del segnale attuale di {entity_name}", + "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_voltage": "Tensione attuale di {entity_name}" @@ -22,12 +31,21 @@ "carbon_monoxide": "Variazioni nella concentrazione di monossido di carbonio di {entity_name}", "current": "variazioni di corrente di {entity_name}", "energy": "variazioni di energia di {entity_name}", + "gas": "Variazioni di gas di {entity_name}", "humidity": "variazioni di umidit\u00e0 di {entity_name} ", "illuminance": "variazioni dell'illuminazione di {entity_name}", + "nitrogen_dioxide": "Variazioni della concentrazione di biossido di azoto di {entity_name}", + "nitrogen_monoxide": "Variazioni della concentrazione di monossido di azoto di {entity_name}", + "nitrous_oxide": "Variazioni della concentrazione di ossidi di azoto di {entity_name}", + "ozone": "Variazioni della concentrazione di ozono di {entity_name}", + "pm1": "Variazioni della concentrazione di PM1 di {entity_name}", + "pm10": "Variazioni della concentrazione di PM10 di {entity_name}", + "pm25": "Variazioni della concentrazione di PM2.5 di {entity_name}", "power": "variazioni di alimentazione di {entity_name}", "power_factor": "variazioni del fattore di potenza di {entity_name}", "pressure": "variazioni della pressione di {entity_name}", "signal_strength": "variazioni della potenza del segnale di {entity_name}", + "sulphur_dioxide": "Variazioni della concentrazione di anidride solforosa di {entity_name}", "temperature": "variazioni di temperatura di {entity_name}", "value": "{entity_name} valori cambiati", "voltage": "variazioni di tensione di {entity_name}" diff --git a/homeassistant/components/sensor/translations/nl.json b/homeassistant/components/sensor/translations/nl.json index 745e097c6ee..c55f1547642 100644 --- a/homeassistant/components/sensor/translations/nl.json +++ b/homeassistant/components/sensor/translations/nl.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "Huidig niveau {entity_name} koolmonoxideconcentratie", "is_current": "Huidige {entity_name} stroom", "is_energy": "Huidige {entity_name} energie", + "is_gas": "Huidig {entity_name} gas", "is_humidity": "Huidige {entity_name} vochtigheidsgraad", "is_illuminance": "Huidige {entity_name} verlichtingssterkte", + "is_nitrogen_dioxide": "Huidige {entity_name} stikstofdioxideconcentratie", + "is_nitrogen_monoxide": "Huidige {entity_name} stikstofmonoxideconcentratie", + "is_nitrous_oxide": "Huidige {entity_name} distikstofmonoxideconcentratie", + "is_ozone": "Huidige {entity_name} ozonconcentratie", + "is_pm1": "Huidige {entity_name} PM1-concentratie", + "is_pm10": "Huidige {entity_name} PM10-concentratie", + "is_pm25": "Huidige {entity_name} PM2.5-concentratie", "is_power": "Huidige {entity_name}\nvermogen", "is_power_factor": "Huidige {entity_name} vermogensfactor", "is_pressure": "Huidige {entity_name} druk", "is_signal_strength": "Huidige {entity_name} signaalsterkte", + "is_sulphur_dioxide": "Huidige {entity_name} zwaveldioxideconcentratie", "is_temperature": "Huidige {entity_name} temperatuur", "is_value": "Huidige {entity_name} waarde", + "is_volatile_organic_compounds": "Huidig {entity_name} vluchtige-organische-stoffenconcentratieniveau", "is_voltage": "Huidige {entity_name} spanning" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "{entity_name} koolmonoxideconcentratie gewijzigd", "current": "{entity_name} huidige wijzigingen", "energy": "{entity_name} energieveranderingen", + "gas": "{entity_name} gas verandert", "humidity": "{entity_name} vochtigheidsgraad gewijzigd", "illuminance": "{entity_name} verlichtingssterkte gewijzigd", + "nitrogen_dioxide": "{entity_name} stikstofdioxideconcentratieverandering", + "nitrogen_monoxide": "{entity_name} stikstofmonoxideconcentratieverandering", + "nitrous_oxide": "{entity_name} distikstofmonoxideconcentratieverandering", + "ozone": "{entity_name} ozonconcentratieveranderingen", + "pm1": "{entity_name} PM1-concentratieveranderingen", + "pm10": "{entity_name} PM10-concentratieveranderingen", + "pm25": "{entity_name} PM2.5-concentratieveranderingen", "power": "{entity_name} vermogen gewijzigd", "power_factor": "{entity_name} power factor verandert", "pressure": "{entity_name} druk gewijzigd", "signal_strength": "{entity_name} signaalsterkte gewijzigd", + "sulphur_dioxide": "{entity_name} zwaveldioxideconcentratieveranderingen", "temperature": "{entity_name} temperatuur gewijzigd", "value": "{entity_name} waarde gewijzigd", + "volatile_organic_compounds": "{entity_name} vluchtige-organische-stoffenconcentratieveranderingen", "voltage": "{entity_name} voltage verandert" } }, diff --git a/homeassistant/components/sensor/translations/no.json b/homeassistant/components/sensor/translations/no.json index 02204a4a49a..1580a716dee 100644 --- a/homeassistant/components/sensor/translations/no.json +++ b/homeassistant/components/sensor/translations/no.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "Gjeldende {entity_name} karbonmonoksid konsentrasjonsniv\u00e5", "is_current": "Gjeldende {entity_name} str\u00f8m", "is_energy": "Gjeldende {entity_name} effekt", + "is_gas": "Gjeldende {entity_name} gass", "is_humidity": "Gjeldende {entity_name} fuktighet", "is_illuminance": "Gjeldende {entity_name} belysningsstyrke", + "is_nitrogen_dioxide": "Gjeldende konsentrasjonsniv\u00e5 for {entity_name}", + "is_nitrogen_monoxide": "Gjeldende {entity_name} nitrogenmonoksidkonsentrasjonsniv\u00e5", + "is_nitrous_oxide": "Gjeldende {entity_name} lystgasskonsentrasjonsniv\u00e5", + "is_ozone": "Gjeldende {entity_name} ozonkonsentrasjonsniv\u00e5", + "is_pm1": "Gjeldende {entity_name} PM1 konsentrasjonsniv\u00e5", + "is_pm10": "Gjeldende konsentrasjonsniv\u00e5 for {entity_name}", + "is_pm25": "Gjeldende {entity_name} PM2.5 konsentrasjonsniv\u00e5", "is_power": "Gjeldende {entity_name}-effekt", "is_power_factor": "Gjeldende {entity_name} effektfaktor", "is_pressure": "Gjeldende {entity_name} trykk", "is_signal_strength": "Gjeldende {entity_name} signalstyrke", + "is_sulphur_dioxide": "Gjeldende konsentrasjonsniv\u00e5 for svoveldioksid for {entity_name}", "is_temperature": "Gjeldende {entity_name} temperatur", "is_value": "Gjeldende {entity_name} verdi", + "is_volatile_organic_compounds": "Gjeldende {entity_name} flyktige organiske forbindelser", "is_voltage": "Gjeldende {entity_name} spenning" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "{entity_name} endringer i konsentrasjonen av karbonmonoksid", "current": "{entity_name} gjeldende endringer", "energy": "{entity_name} effektendringer", + "gas": "{entity_name} gass endres", "humidity": "{entity_name} fuktighets endringer", "illuminance": "{entity_name} belysningsstyrke endringer", + "nitrogen_dioxide": "{entity_name} nitrogendioksidkonsentrasjonsendringer", + "nitrogen_monoxide": "{entity_name} nitrogenmonoksidkonsentrasjonsendringer", + "nitrous_oxide": "{entity_name} endringer i nitrogenoksidskonsentrasjonen", + "ozone": "{entity_name} ozonkonsentrasjonsendringer", + "pm1": "{entity_name} PM1 -konsentrasjon endres", + "pm10": "{entity_name} PM10 -konsentrasjon endres", + "pm25": "{entity_name} PM2.5 konsentrasjon endres", "power": "{entity_name} effektendringer", "power_factor": "{entity_name} effektfaktorendringer", "pressure": "{entity_name} trykk endringer", "signal_strength": "{entity_name} signalstyrkeendringer", + "sulphur_dioxide": "{entity_name} svoveldioksidkonsentrasjon endres", "temperature": "{entity_name} temperaturendringer", "value": "{entity_name} verdi endringer", + "volatile_organic_compounds": "{entity_name} konsentrasjon av flyktige organiske forbindelser", "voltage": "{entity_name} spenningsendringer" } }, diff --git a/homeassistant/components/sensor/translations/pl.json b/homeassistant/components/sensor/translations/pl.json index fe47bfd902c..2a82919e42e 100644 --- a/homeassistant/components/sensor/translations/pl.json +++ b/homeassistant/components/sensor/translations/pl.json @@ -2,16 +2,25 @@ "device_automation": { "condition_type": { "is_battery_level": "obecny poziom na\u0142adowania baterii {entity_name}", - "is_carbon_dioxide": "Bie\u017c\u0105cy poziom st\u0119\u017cenia dwutlenku w\u0119gla w {entity_name}", - "is_carbon_monoxide": "Bie\u017c\u0105cy poziom st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", + "is_carbon_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku w\u0119gla w {entity_name}", + "is_carbon_monoxide": "obecny poziom st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", "is_current": "obecne nat\u0119\u017cenie pr\u0105du {entity_name}", "is_energy": "obecna energia {entity_name}", + "is_gas": "obecny poziom gazu {entity_name}", "is_humidity": "obecna wilgotno\u015b\u0107 {entity_name}", "is_illuminance": "obecne nat\u0119\u017cenie o\u015bwietlenia {entity_name}", + "is_nitrogen_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku azotu {entity_name}", + "is_nitrogen_monoxide": "obecny poziom st\u0119\u017cenia tlenku azotu {entity_name}", + "is_nitrous_oxide": "obecny poziom st\u0119\u017cenia podtlenku azotu {entity_name}", + "is_ozone": "obecny poziom st\u0119\u017cenia ozonu {entity_name}", + "is_pm1": "obecny poziom st\u0119\u017cenia PM1 {entity_name}", + "is_pm10": "obecny poziom st\u0119\u017cenia PM10 {entity_name}", + "is_pm25": "obecny poziom st\u0119\u017cenia PM2.5 {entity_name}", "is_power": "obecna moc {entity_name}", "is_power_factor": "obecny wsp\u00f3\u0142czynnik mocy {entity_name}", "is_pressure": "obecne ci\u015bnienie {entity_name}", "is_signal_strength": "obecna si\u0142a sygna\u0142u {entity_name}", + "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_voltage": "obecne napi\u0119cie {entity_name}" @@ -22,12 +31,21 @@ "carbon_monoxide": "Zmiana st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", "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}", "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}", "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}", "temperature": "zmieni si\u0119 temperatura {entity_name}", "value": "zmieni si\u0119 warto\u015b\u0107 {entity_name}", "voltage": "zmieni si\u0119 napi\u0119cie w {entity_name}" diff --git a/homeassistant/components/sensor/translations/ru.json b/homeassistant/components/sensor/translations/ru.json index c44c9002fef..821622ae20c 100644 --- a/homeassistant/components/sensor/translations/ru.json +++ b/homeassistant/components/sensor/translations/ru.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0433\u0430\u0440\u043d\u043e\u0433\u043e \u0433\u0430\u0437\u0430", "is_current": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u0438\u043b\u044b \u0442\u043e\u043a\u0430", "is_energy": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", + "is_gas": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_humidity": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_illuminance": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "is_nitrogen_dioxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0430\u0437\u043e\u0442\u0430", + "is_nitrogen_monoxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043c\u043e\u043d\u043e\u043e\u043a\u0441\u0438\u0434\u0430 \u0430\u0437\u043e\u0442\u0430", + "is_nitrous_oxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043a\u0438\u0441\u0438 \u0430\u0437\u043e\u0442\u0430", + "is_ozone": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0437\u043e\u043d\u0430", + "is_pm1": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM1", + "is_pm10": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM10", + "is_pm25": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM2.5", "is_power": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_power_factor": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u044d\u0444\u0444\u0438\u0446\u0438\u0435\u043d\u0442\u0430 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", "is_pressure": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_signal_strength": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "is_sulphur_dioxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0441\u0435\u0440\u044b", "is_temperature": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_value": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "is_volatile_organic_compounds": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043b\u0435\u0442\u0443\u0447\u0438\u0445 \u043e\u0440\u0433\u0430\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0445 \u0432\u0435\u0449\u0435\u0441\u0442\u0432", "is_voltage": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "current": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u0438\u043b\u044b \u0442\u043e\u043a\u0430", "energy": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", + "gas": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435 \u0433\u0430\u0437\u0430", "humidity": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "illuminance": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "nitrogen_dioxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0430\u0437\u043e\u0442\u0430", + "nitrogen_monoxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043c\u043e\u043d\u043e\u043e\u043a\u0441\u0438\u0434\u0430 \u0430\u0437\u043e\u0442\u0430", + "nitrous_oxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043a\u0438\u0441\u0438 \u0430\u0437\u043e\u0442\u0430", + "ozone": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0437\u043e\u043d\u0430", + "pm1": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM1", + "pm10": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM10", + "pm25": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 PM2.5", "power": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "power_factor": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u043a\u043e\u044d\u0444\u0444\u0438\u0446\u0438\u0435\u043d\u0442 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", "pressure": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "signal_strength": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "sulphur_dioxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0441\u0435\u0440\u044b", "temperature": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "value": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "volatile_organic_compounds": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043b\u0435\u0442\u0443\u0447\u0438\u0445 \u043e\u0440\u0433\u0430\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0445 \u0432\u0435\u0449\u0435\u0441\u0442\u0432", "voltage": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" } }, diff --git a/homeassistant/components/sensor/translations/zh-Hant.json b/homeassistant/components/sensor/translations/zh-Hant.json index a15af383da6..fb15fc70402 100644 --- a/homeassistant/components/sensor/translations/zh-Hant.json +++ b/homeassistant/components/sensor/translations/zh-Hant.json @@ -6,14 +6,24 @@ "is_carbon_monoxide": "\u76ee\u524d {entity_name} \u4e00\u6c27\u5316\u78b3\u6fc3\u5ea6\u72c0\u614b", "is_current": "\u76ee\u524d{entity_name}\u96fb\u6d41", "is_energy": "\u76ee\u524d{entity_name}\u96fb\u529b", + "is_gas": "\u76ee\u524d{entity_name}\u6c23\u9ad4", "is_humidity": "\u76ee\u524d{entity_name}\u6fd5\u5ea6", "is_illuminance": "\u76ee\u524d{entity_name}\u7167\u5ea6", + "is_nitrogen_dioxide": "\u76ee\u524d {entity_name} \u4e8c\u6c27\u5316\u6c2e\u6fc3\u5ea6\u72c0\u614b", + "is_nitrogen_monoxide": "\u76ee\u524d {entity_name} \u4e00\u6c27\u5316\u6c2e\u6fc3\u5ea6\u72c0\u614b", + "is_nitrous_oxide": "\u76ee\u524d {entity_name} \u4e00\u6c27\u5316\u4e8c\u6c2e\u6fc3\u5ea6\u72c0\u614b", + "is_ozone": "\u76ee\u524d {entity_name} \u81ed\u6c27\u6fc3\u5ea6\u72c0\u614b", + "is_pm1": "\u76ee\u524d {entity_name} PM1 \u6fc3\u5ea6\u72c0\u614b", + "is_pm10": "\u76ee\u524d {entity_name} PM10 \u6fc3\u5ea6\u72c0\u614b", + "is_pm25": "\u76ee\u524d {entity_name} PM2.5 \u6fc3\u5ea6\u72c0\u614b", "is_power": "\u76ee\u524d{entity_name}\u96fb\u529b", "is_power_factor": "\u76ee\u524d{entity_name}\u529f\u7387\u56e0\u6578", "is_pressure": "\u76ee\u524d{entity_name}\u58d3\u529b", "is_signal_strength": "\u76ee\u524d{entity_name}\u8a0a\u865f\u5f37\u5ea6", + "is_sulphur_dioxide": "\u76ee\u524d {entity_name} \u4e8c\u6c27\u5316\u786b\u6fc3\u5ea6\u72c0\u614b", "is_temperature": "\u76ee\u524d{entity_name}\u6eab\u5ea6", "is_value": "\u76ee\u524d{entity_name}\u503c", + "is_volatile_organic_compounds": "\u76ee\u524d {entity_name} \u63ee\u767c\u6027\u6709\u6a5f\u7269\u6fc3\u5ea6\u72c0\u614b", "is_voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3" }, "trigger_type": { @@ -22,14 +32,24 @@ "carbon_monoxide": "{entity_name} \u4e00\u6c27\u5316\u78b3\u6fc3\u5ea6\u8b8a\u5316", "current": "\u76ee\u524d{entity_name}\u96fb\u6d41\u8b8a\u66f4", "energy": "\u76ee\u524d{entity_name}\u96fb\u529b\u8b8a\u66f4", + "gas": "{entity_name}\u6c23\u9ad4\u8b8a\u66f4", "humidity": "{entity_name}\u6fd5\u5ea6\u8b8a\u66f4", "illuminance": "{entity_name}\u7167\u5ea6\u8b8a\u66f4", + "nitrogen_dioxide": "{entity_name} \u4e8c\u6c27\u5316\u6c2e\u6fc3\u5ea6\u8b8a\u5316", + "nitrogen_monoxide": "{entity_name} \u4e00\u6c27\u5316\u6c2e\u6fc3\u5ea6\u8b8a\u5316", + "nitrous_oxide": "{entity_name} \u4e00\u6c27\u5316\u4e8c\u6c2e\u6fc3\u5ea6\u8b8a\u5316", + "ozone": "{entity_name} \u81ed\u6c27\u6fc3\u5ea6\u8b8a\u5316", + "pm1": "{entity_name} PM1 \u6fc3\u5ea6\u8b8a\u5316", + "pm10": "{entity_name} PM10 \u6fc3\u5ea6\u8b8a\u5316", + "pm25": "{entity_name} PM2.5 \u6fc3\u5ea6\u8b8a\u5316", "power": "{entity_name}\u96fb\u529b\u8b8a\u66f4", "power_factor": "\u76ee\u524d{entity_name}\u529f\u7387\u56e0\u6578\u8b8a\u66f4", "pressure": "{entity_name}\u58d3\u529b\u8b8a\u66f4", "signal_strength": "{entity_name}\u8a0a\u865f\u5f37\u5ea6\u8b8a\u66f4", + "sulphur_dioxide": "{entity_name} \u4e8c\u6c27\u5316\u786b\u6fc3\u5ea6\u8b8a\u5316", "temperature": "{entity_name}\u6eab\u5ea6\u8b8a\u66f4", "value": "{entity_name}\u503c\u8b8a\u66f4", + "volatile_organic_compounds": "{entity_name} \u63ee\u767c\u6027\u6709\u6a5f\u7269\u6fc3\u5ea6\u8b8a\u5316", "voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3\u8b8a\u66f4" } }, diff --git a/homeassistant/components/sentry/translations/hu.json b/homeassistant/components/sentry/translations/hu.json index 79188df18b1..df07c41449e 100644 --- a/homeassistant/components/sentry/translations/hu.json +++ b/homeassistant/components/sentry/translations/hu.json @@ -25,7 +25,10 @@ "event_custom_components": "Esem\u00e9nyek k\u00fcld\u00e9se egy\u00e9ni \u00f6sszetev\u0151kb\u0151l", "event_handled": "K\u00fcldj\u00f6n kezelt esem\u00e9nyeket", "event_third_party_packages": "K\u00fcldj\u00f6n esem\u00e9nyeket harmadik f\u00e9l csomagjaib\u00f3l", - "tracing": "Enged\u00e9lyezze a teljes\u00edtm\u00e9nyk\u00f6vet\u00e9st" + "logging_event_level": "A napl\u00f3szint\u0171 Sentry esem\u00e9ny regisztr\u00e1l\u00e1sa", + "logging_level": "A napl\u00f3szint\u0171 Sentry a napl\u00f3k t\u00f6red\u00e9keinek r\u00f6gz\u00edt\u00e9se", + "tracing": "Enged\u00e9lyezze a teljes\u00edtm\u00e9nyk\u00f6vet\u00e9st", + "tracing_sample_rate": "A mintav\u00e9teli sebess\u00e9g nyomon k\u00f6vet\u00e9se; 0,0 \u00e9s 1,0 k\u00f6z\u00f6tt (1,0 = 100%)" } } } diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index 1e73ae9ac83..cbdba50b6b6 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -245,6 +245,6 @@ class SerialSensor(SensorEntity): return self._attributes @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index fd017661de2..9332f268308 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -71,12 +71,12 @@ class ParticulateMatterSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index acd71b7c9e7..261b2680499 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -1,17 +1,11 @@ """Support for Sesame, by CANDY HOUSE.""" -from typing import Callable +from __future__ import annotations import pysesame2 import voluptuous as vol from homeassistant.components.lock import PLATFORM_SCHEMA, LockEntity -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_DEVICE_ID, - CONF_API_KEY, - STATE_LOCKED, - STATE_UNLOCKED, -) +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -20,9 +14,7 @@ ATTR_SERIAL_NO = "serial" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) -def setup_platform( - hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None -): +def setup_platform(hass, config: ConfigType, add_entities, discovery_info=None): """Set up the Sesame platform.""" api_key = config.get(CONF_API_KEY) @@ -35,20 +27,20 @@ def setup_platform( class SesameDevice(LockEntity): """Representation of a Sesame device.""" - def __init__(self, sesame: object) -> None: + def __init__(self, sesame: pysesame2.Sesame) -> None: """Initialize the Sesame device.""" - self._sesame = sesame + self._sesame: pysesame2.Sesame = sesame # Cached properties from pysesame object. - self._device_id = None + self._device_id: str | None = None self._serial = None - self._nickname = None + self._nickname: str | None = None self._is_locked = False self._responsive = False self._battery = -1 @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the device.""" return self._nickname @@ -62,11 +54,6 @@ class SesameDevice(LockEntity): """Return True if the device is currently locked, else False.""" return self._is_locked - @property - def state(self) -> str: - """Get the state of the device.""" - return STATE_LOCKED if self._is_locked else STATE_UNLOCKED - def lock(self, **kwargs) -> None: """Lock the device.""" self._sesame.lock() diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index ab0f0779656..44720db2fcb 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -125,7 +125,7 @@ class SeventeenTrackSummarySensor(SensorEntity): return f"Seventeentrack Packages {self._status}" @property - def state(self): + def native_value(self): """Return the state.""" return self._state @@ -135,7 +135,7 @@ class SeventeenTrackSummarySensor(SensorEntity): return f"summary_{self._data.account_id}_{slugify(self._status)}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return "packages" @@ -211,7 +211,7 @@ class SeventeenTrackPackageSensor(SensorEntity): return f"Seventeentrack Package: {name}" @property - def state(self): + def native_value(self): """Return the state.""" return self._state diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index dd1b3a9d66d..f4b2daf8159 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,7 +1,7 @@ """Binary sensor for Shelly.""" from __future__ import annotations -from typing import Final +from typing import Final, cast from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, @@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_PROBLEM, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_UPDATE, DEVICE_CLASS_VIBRATION, STATE_ON, BinarySensorEntity, @@ -45,7 +46,9 @@ SENSORS: Final = { name="Overpowering", device_class=DEVICE_CLASS_PROBLEM ), ("sensor", "dwIsOpened"): BlockAttributeDescription( - name="Door", device_class=DEVICE_CLASS_OPENING + name="Door", + device_class=DEVICE_CLASS_OPENING, + available=lambda block: cast(bool, block.dwIsOpened != -1), ), ("sensor", "flood"): BlockAttributeDescription( name="Flood", device_class=DEVICE_CLASS_MOISTURE @@ -99,7 +102,7 @@ REST_SENSORS: Final = { ), "fwupdate": RestAttributeDescription( name="Firmware Update", - icon="mdi:update", + device_class=DEVICE_CLASS_UPDATE, value=lambda status, _: status["update"]["has_update"], default_enabled=False, extra_state_attributes=lambda status: { diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 49e33dfd5e1..5646086285d 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -1,6 +1,7 @@ """Constants for the Shelly integration.""" from __future__ import annotations +import re from typing import Final COAP: Final = "coap" @@ -11,6 +12,22 @@ REST: Final = "rest" CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 +FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") + +# Firmware 1.11.0 release date, this firmware supports light transition +LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 + +# max light transition time in milliseconds +MAX_TRANSITION_TIME: Final = 5000 + +MODELS_SUPPORTING_LIGHT_TRANSITION: Final = ( + "SHBDUO-1", + "SHCB-1", + "SHDM-1", + "SHDM-2", + "SHRGBW2", + "SHVIN-1", +) # Used in "_async_update_data" as timeout for polling data from devices. POLLING_TIMEOUT_SEC: Final = 18 @@ -92,6 +109,3 @@ KELVIN_MIN_VALUE_WHITE: Final = 2700 KELVIN_MIN_VALUE_COLOR: Final = 3000 UPTIME_DEVIATION: Final = 5 - -LAST_RESET_UPTIME: Final = "uptime" -LAST_RESET_NEVER: Final = "never" diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index bcb909555a9..c44dd279230 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -69,7 +69,7 @@ async def async_validate_trigger_config( async def async_get_triggers( hass: HomeAssistant, device_id: str -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: """List device triggers for Shelly devices.""" triggers = [] diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 0d23f5abffc..743dd07414e 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -179,7 +179,6 @@ class BlockAttributeDescription: # Callable (settings, block), return true if entity should be removed removal_condition: Callable[[dict, aioshelly.Block], bool] | None = None extra_state_attributes: Callable[[aioshelly.Block], dict | None] | None = None - last_reset: str | None = None @dataclass diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 047a105a30f..86624410708 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -14,12 +14,14 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, + ATTR_TRANSITION, COLOR_MODE_BRIGHTNESS, COLOR_MODE_COLOR_TEMP, COLOR_MODE_ONOFF, COLOR_MODE_RGB, COLOR_MODE_RGBW, SUPPORT_EFFECT, + SUPPORT_TRANSITION, LightEntity, brightness_supported, ) @@ -37,9 +39,13 @@ from .const import ( COAP, DATA_CONFIG_ENTRY, DOMAIN, + FIRMWARE_PATTERN, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, + LIGHT_TRANSITION_MIN_FIRMWARE_DATE, + MAX_TRANSITION_TIME, + MODELS_SUPPORTING_LIGHT_TRANSITION, SHBLB_1_RGB_EFFECTS, STANDARD_RGB_EFFECTS, ) @@ -110,6 +116,14 @@ class ShellyLight(ShellyBlockEntity, LightEntity): if hasattr(block, "effect"): self._supported_features |= SUPPORT_EFFECT + if wrapper.model in MODELS_SUPPORTING_LIGHT_TRANSITION: + match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw")) + if ( + match is not None + and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE + ): + self._supported_features |= SUPPORT_TRANSITION + @property def supported_features(self) -> int: """Supported features.""" @@ -261,6 +275,11 @@ class ShellyLight(ShellyBlockEntity, LightEntity): supported_color_modes = self._supported_color_modes params: dict[str, Any] = {"turn": "on"} + if ATTR_TRANSITION in kwargs: + params["transition"] = min( + int(kwargs[ATTR_TRANSITION] * 1000), MAX_TRANSITION_TIME + ) + if ATTR_BRIGHTNESS in kwargs and brightness_supported(supported_color_modes): brightness_pct = int(100 * (kwargs[ATTR_BRIGHTNESS] + 1) / 255) if hasattr(self.block, "gain"): @@ -312,7 +331,15 @@ class ShellyLight(ShellyBlockEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" - self.control_result = await self.set_state(turn="off") + params: dict[str, Any] = {"turn": "off"} + + if ATTR_TRANSITION in kwargs: + params["transition"] = min( + int(kwargs[ATTR_TRANSITION] * 1000), MAX_TRANSITION_TIME + ) + + self.control_result = await self.set_state(**params) + self.async_write_ha_state() async def set_light_mode(self, set_mode: str | None) -> bool: diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 07e4f4a4fe3..d8d530ed94c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,12 +1,8 @@ """Sensor for Shelly.""" from __future__ import annotations -from datetime import timedelta -import logging from typing import Final, cast -import aioshelly - from homeassistant.components import sensor from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -24,10 +20,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.util import dt -from . import ShellyDeviceWrapper -from .const import LAST_RESET_NEVER, LAST_RESET_UPTIME, SHAIR_MAX_WORK_HOURS +from .const import SHAIR_MAX_WORK_HOURS from .entity import ( BlockAttributeDescription, RestAttributeDescription, @@ -39,8 +33,6 @@ from .entity import ( ) from .utils import get_device_uptime, temperature_unit -_LOGGER: Final = logging.getLogger(__name__) - SENSORS: Final = { ("device", "battery"): BlockAttributeDescription( name="Battery", @@ -48,6 +40,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), ), ("device", "deviceTemp"): BlockAttributeDescription( name="Device Temperature", @@ -119,49 +112,43 @@ SENSORS: Final = { unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_UPTIME, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("emeter", "energy"): BlockAttributeDescription( name="Energy", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_NEVER, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("emeter", "energyReturned"): BlockAttributeDescription( name="Energy Returned", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_NEVER, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("light", "energy"): BlockAttributeDescription( name="Energy", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, default_enabled=False, - last_reset=LAST_RESET_UPTIME, ), ("relay", "energy"): BlockAttributeDescription( name="Energy", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_UPTIME, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("roller", "rollerEnergy"): BlockAttributeDescription( name="Energy", unit=ENERGY_KILO_WATT_HOUR, value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, - state_class=sensor.STATE_CLASS_MEASUREMENT, - last_reset=LAST_RESET_UPTIME, + state_class=sensor.STATE_CLASS_TOTAL_INCREASING, ), ("sensor", "concentration"): BlockAttributeDescription( name="Gas Concentration", @@ -190,6 +177,7 @@ SENSORS: Final = { unit=LIGHT_LUX, device_class=sensor.DEVICE_CLASS_ILLUMINANCE, state_class=sensor.STATE_CLASS_MEASUREMENT, + available=lambda block: cast(bool, block.luminosity != -1), ), ("sensor", "tilt"): BlockAttributeDescription( name="Tilt", @@ -261,39 +249,9 @@ async def async_setup_entry( class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): """Represent a shelly sensor.""" - def __init__( - self, - wrapper: ShellyDeviceWrapper, - block: aioshelly.Block, - attribute: str, - description: BlockAttributeDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(wrapper, block, attribute, description) - self._last_value: float | None = None - - if description.last_reset == LAST_RESET_NEVER: - self._attr_last_reset = dt.utc_from_timestamp(0) - elif description.last_reset == LAST_RESET_UPTIME: - self._attr_last_reset = ( - dt.utcnow() - timedelta(seconds=wrapper.device.status["uptime"]) - ).replace(second=0, microsecond=0) - @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return value of sensor.""" - if ( - self.description.last_reset == LAST_RESET_UPTIME - and self.attribute_value is not None - ): - value = cast(float, self.attribute_value) - - if self._last_value and self._last_value > value: - self._attr_last_reset = dt.utcnow().replace(second=0, microsecond=0) - _LOGGER.info("Energy reset detected for entity %s", self.name) - - self._last_value = value - return self.attribute_value @property @@ -302,7 +260,7 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): return self.description.state_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of sensor.""" return cast(str, self._unit) @@ -311,7 +269,7 @@ class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): """Represent a shelly REST sensor.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return value of sensor.""" return self.attribute_value @@ -321,7 +279,7 @@ class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): return self.description.state_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of sensor.""" return self.description.unit @@ -330,7 +288,7 @@ class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): """Represent a shelly sleeping sensor.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return value of sensor.""" if self.block is not None: return self.attribute_value @@ -343,6 +301,6 @@ class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): return self.description.state_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of sensor.""" return cast(str, self._unit) diff --git a/homeassistant/components/shelly/translations/hu.json b/homeassistant/components/shelly/translations/hu.json index 2c8f468aaed..9388e26515a 100644 --- a/homeassistant/components/shelly/translations/hu.json +++ b/homeassistant/components/shelly/translations/hu.json @@ -11,6 +11,9 @@ }, "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." + }, "credentials": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/shodan/sensor.py b/homeassistant/components/shodan/sensor.py index fa0fc2d3906..1423a3b9327 100644 --- a/homeassistant/components/shodan/sensor.py +++ b/homeassistant/components/shodan/sensor.py @@ -62,12 +62,12 @@ class ShodanSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 841865cd759..49b4d8a5d91 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -12,7 +12,15 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json -from .const import DOMAIN +from .const import ( + DOMAIN, + SERVICE_ADD_ITEM, + SERVICE_CLEAR_COMPLETED_ITEMS, + SERVICE_COMPLETE_ALL, + SERVICE_COMPLETE_ITEM, + SERVICE_INCOMPLETE_ALL, + SERVICE_INCOMPLETE_ITEM, +) ATTR_COMPLETE = "complete" @@ -22,11 +30,6 @@ EVENT = "shopping_list_updated" ITEM_UPDATE_SCHEMA = vol.Schema({ATTR_COMPLETE: bool, ATTR_NAME: str}) PERSISTENCE = ".shopping_list.json" -SERVICE_ADD_ITEM = "add_item" -SERVICE_COMPLETE_ITEM = "complete_item" -SERVICE_INCOMPLETE_ITEM = "incomplete_item" -SERVICE_COMPLETE_ALL = "complete_all" -SERVICE_INCOMPLETE_ALL = "incomplete_all" SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): vol.Any(None, cv.string)}) SERVICE_LIST_SCHEMA = vol.Schema({}) @@ -116,6 +119,10 @@ async def async_setup_entry(hass, config_entry): """Mark all items in the list as incomplete.""" await data.async_update_list({"complete": False}) + async def clear_completed_items_service(call): + """Clear all completed items from the list.""" + await data.async_clear_completed() + data = hass.data[DOMAIN] = ShoppingData(hass) await data.async_load() @@ -143,6 +150,12 @@ async def async_setup_entry(hass, config_entry): incomplete_all_service, schema=SERVICE_LIST_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_COMPLETED_ITEMS, + clear_completed_items_service, + schema=SERVICE_LIST_SCHEMA, + ) hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py index 4878d317780..2969fc8f86d 100644 --- a/homeassistant/components/shopping_list/const.py +++ b/homeassistant/components/shopping_list/const.py @@ -1,2 +1,9 @@ """All constants related to the shopping list component.""" DOMAIN = "shopping_list" + +SERVICE_ADD_ITEM = "add_item" +SERVICE_COMPLETE_ITEM = "complete_item" +SERVICE_INCOMPLETE_ITEM = "incomplete_item" +SERVICE_COMPLETE_ALL = "complete_all" +SERVICE_INCOMPLETE_ALL = "incomplete_all" +SERVICE_CLEAR_COMPLETED_ITEMS = "clear_completed_items" diff --git a/homeassistant/components/shopping_list/services.yaml b/homeassistant/components/shopping_list/services.yaml index 7bf209550d7..0af388cfcb1 100644 --- a/homeassistant/components/shopping_list/services.yaml +++ b/homeassistant/components/shopping_list/services.yaml @@ -40,3 +40,7 @@ complete_all: incomplete_all: name: Incomplete all description: Marks all items as incomplete in the shopping list. + +clear_completed_items: + name: Clear completed items + description: Clear completed items from the shopping list. diff --git a/homeassistant/components/shopping_list/translations/zh-Hans.json b/homeassistant/components/shopping_list/translations/zh-Hans.json new file mode 100644 index 00000000000..fa498b4ff60 --- /dev/null +++ b/homeassistant/components/shopping_list/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u914d\u7f6e" + }, + "step": { + "user": { + "description": "\u60a8\u8981\u914d\u7f6e\u8d2d\u7269\u6e05\u5355\u5417\uff1f", + "title": "\u8d2d\u7269\u6e05\u5355" + } + } + }, + "title": "\u8d2d\u7269\u6e05\u5355" +} \ No newline at end of file diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py index a894623db47..e5f77700409 100644 --- a/homeassistant/components/sht31/sensor.py +++ b/homeassistant/components/sht31/sensor.py @@ -13,11 +13,9 @@ from homeassistant.const import ( CONF_NAME, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, - PRECISION_TENTHS, TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.temperature import display_temp from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -108,7 +106,7 @@ class SHTSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -121,27 +119,19 @@ class SHTSensorTemperature(SHTSensor): """Representation of a temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self.hass.config.units.temperature_unit + _attr_native_unit_of_measurement = TEMP_CELSIUS def update(self): """Fetch temperature from the sensor.""" super().update() - temp_celsius = self._sensor.temperature - if temp_celsius is not None: - self._state = display_temp( - self.hass, temp_celsius, TEMP_CELSIUS, PRECISION_TENTHS - ) + self._state = self._sensor.temperature class SHTSensorHumidity(SHTSensor): """Representation of a humidity sensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return PERCENTAGE diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index 75c2a4f0f63..41fb3469293 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -149,7 +149,7 @@ class SigfoxDevice(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the payload of the last message.""" return self._state diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index e912eedb955..e5f6faba10f 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -16,7 +16,6 @@ from . import SimpliSafe, SimpliSafeEntity from .const import DATA_CLIENT, DOMAIN, LOGGER ATTR_LOCK_LOW_BATTERY = "lock_low_battery" -ATTR_JAMMED = "jammed" ATTR_PIN_PAD_LOW_BATTERY = "pin_pad_low_battery" @@ -47,7 +46,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): self._lock = lock - async def async_lock(self, **kwargs: dict[str, Any]) -> None: + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" try: await self._lock.lock() @@ -58,7 +57,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): self._attr_is_locked = True self.async_write_ha_state() - async def async_unlock(self, **kwargs: dict[str, Any]) -> None: + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" try: await self._lock.unlock() @@ -75,9 +74,9 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): self._attr_extra_state_attributes.update( { ATTR_LOCK_LOW_BATTERY: self._lock.lock_low_battery, - ATTR_JAMMED: self._lock.state == LockStates.jammed, ATTR_PIN_PAD_LOW_BATTERY: self._lock.pin_pad_low_battery, } ) + self._attr_is_jammed = self._lock.state == LockStates.jammed self._attr_is_locked = self._lock.state == LockStates.locked diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 8c23e575cc3..c6bc3ae61fa 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.3"], + "requirements": ["simplisafe-python==11.0.6"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index 149319cd5bd..c3f8d7c3ab0 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -34,9 +34,9 @@ class SimplisafeFreezeSensor(SimpliSafeBaseSensor, SensorEntity): """Define a SimpliSafe freeze sensor entity.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_FAHRENHEIT + _attr_native_unit_of_measurement = TEMP_FAHRENHEIT @callback def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" - self._attr_state = self._sensor.temperature + self._attr_native_value = self._sensor.temperature diff --git a/homeassistant/components/simplisafe/translations/he.json b/homeassistant/components/simplisafe/translations/he.json index 6dcf2c0c07b..dda9553f48d 100644 --- a/homeassistant/components/simplisafe/translations/he.json +++ b/homeassistant/components/simplisafe/translations/he.json @@ -12,7 +12,7 @@ "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4" }, - "description": "\u05ea\u05d5\u05e7\u05e3 \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05da \u05e4\u05d2 \u05d0\u05d5 \u05d1\u05d5\u05d8\u05dc. \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da.", + "description": "\u05ea\u05d5\u05e7\u05e3 \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05da \u05e4\u05d2 \u05d0\u05d5 \u05d1\u05d5\u05d8\u05dc. \u05d9\u05e9 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da.", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" }, "user": { diff --git a/homeassistant/components/simplisafe/translations/hu.json b/homeassistant/components/simplisafe/translations/hu.json index 8a2deedc534..f7c1b5afd9d 100644 --- a/homeassistant/components/simplisafe/translations/hu.json +++ b/homeassistant/components/simplisafe/translations/hu.json @@ -1,18 +1,25 @@ { "config": { "abort": { + "already_configured": "Ez a SimpliSafe-fi\u00f3k m\u00e1r haszn\u00e1latban van.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { "identifier_exists": "Fi\u00f3k m\u00e1r regisztr\u00e1lva van", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "still_awaiting_mfa": "M\u00e9g v\u00e1r az MFA e-mail kattint\u00e1sra", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "mfa": { + "description": "Ellen\u0151rizze e-mailj\u00e9ben a SimpliSafe linkj\u00e9t. A link ellen\u0151rz\u00e9se ut\u00e1n t\u00e9rjen vissza ide, \u00e9s fejezze be az integr\u00e1ci\u00f3 telep\u00edt\u00e9s\u00e9t.", + "title": "SimpliSafe t\u00f6bbt\u00e9nyez\u0151s hiteles\u00edt\u00e9s" + }, "reauth_confirm": { "data": { "password": "Jelsz\u00f3" }, + "description": "Hozz\u00e1f\u00e9r\u00e9se lej\u00e1rt vagy visszavont\u00e1k. Adja meg jelszav\u00e1t a fi\u00f3k \u00fajb\u00f3li \u00f6sszekapcsol\u00e1s\u00e1hoz.", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, "user": { @@ -24,5 +31,15 @@ "title": "T\u00f6ltsd ki az adataid" } } + }, + "options": { + "step": { + "init": { + "data": { + "code": "K\u00f3d (a Home Assistant felhaszn\u00e1l\u00f3i fel\u00fclet\u00e9n haszn\u00e1latos)" + }, + "title": "A SimpliSafe konfigur\u00e1l\u00e1sa" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/simplisafe/translations/no.json b/homeassistant/components/simplisafe/translations/no.json index bc82715ad63..acd8adf0792 100644 --- a/homeassistant/components/simplisafe/translations/no.json +++ b/homeassistant/components/simplisafe/translations/no.json @@ -19,7 +19,7 @@ "data": { "password": "Passord" }, - "description": "Din tilgang har utl\u00f8pt eller blitt tilbakekalt. Skriv inn passordet ditt for \u00e5 koble kontoen din p\u00e5 nytt.", + "description": "Tilgangen din har utl\u00f8pt eller blitt tilbakekalt. Skriv inn passordet ditt for \u00e5 koble kontoen din til p\u00e5 nytt.", "title": "Godkjenne integrering p\u00e5 nytt" }, "user": { diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py index 3fe7aedfbb0..819f9c7147c 100644 --- a/homeassistant/components/simulated/sensor.py +++ b/homeassistant/components/simulated/sensor.py @@ -121,7 +121,7 @@ class SimulatedSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -131,7 +131,7 @@ class SimulatedSensor(SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index f301100fa6c..ed0e8b8645f 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -64,10 +64,29 @@ def process_turn_on_params( if not supported_features & SUPPORT_TONES: params.pop(ATTR_TONE, None) - elif (tone := params.get(ATTR_TONE)) is not None and ( - not siren.available_tones or tone not in siren.available_tones - ): - raise ValueError(f"Invalid tone received for entity {siren.entity_id}: {tone}") + elif (tone := params.get(ATTR_TONE)) is not None: + # Raise an exception if the specified tone isn't available + is_tone_dict_value = bool( + isinstance(siren.available_tones, dict) + and tone in siren.available_tones.values() + ) + if ( + not siren.available_tones + or tone not in siren.available_tones + and not is_tone_dict_value + ): + raise ValueError( + f"Invalid tone specified for entity {siren.entity_id}: {tone}, " + "check the available_tones attribute for valid tones to pass in" + ) + + # If available tones is a dict, and the tone provided is a dict value, we need + # to transform it to the corresponding dict key before returning + if is_tone_dict_value: + assert isinstance(siren.available_tones, dict) + params[ATTR_TONE] = next( + key for key, value in siren.available_tones.items() if value == tone + ) if not supported_features & SUPPORT_DURATION: params.pop(ATTR_DURATION, None) @@ -131,7 +150,7 @@ class SirenEntity(ToggleEntity): """Representation of a siren device.""" entity_description: SirenEntityDescription - _attr_available_tones: list[int | str] | None = None + _attr_available_tones: list[int | str] | dict[int, str] | None = None @final @property @@ -145,7 +164,7 @@ class SirenEntity(ToggleEntity): return None @property - def available_tones(self) -> list[int | str] | None: + def available_tones(self) -> list[int | str] | dict[int, str] | None: """ Return a list of available tones. diff --git a/homeassistant/components/siren/services.yaml b/homeassistant/components/siren/services.yaml index 8c5ed3be974..18bf782eaf2 100644 --- a/homeassistant/components/siren/services.yaml +++ b/homeassistant/components/siren/services.yaml @@ -7,7 +7,7 @@ turn_on: domain: siren fields: tone: - description: The tone to emit when turning the siren on. Must be supported by the integration. + description: The tone to emit when turning the siren on. When `available_tones` property is a map, either the key or the value can be used. Must be supported by the integration. example: fire required: false selector: diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 5b6eae96a7e..a72e1372ca0 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -65,7 +65,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class SkybeaconHumid(SensorEntity): """Representation of a Skybeacon humidity sensor.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, name, mon): """Initialize a sensor.""" @@ -78,7 +78,7 @@ class SkybeaconHumid(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.mon.data["humid"] @@ -92,7 +92,7 @@ class SkybeaconTemp(SensorEntity): """Representation of a Skybeacon temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__(self, name, mon): """Initialize a sensor.""" @@ -105,7 +105,7 @@ class SkybeaconTemp(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self.mon.data["temp"] diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 87dc3c0bf8d..20e93fb90f7 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -1,4 +1,6 @@ """Camera support for the Skybell HD Doorbell.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -75,7 +77,9 @@ class SkybellCamera(SkybellDevice, Camera): return self._device.activity_image return self._device.image - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get the latest camera image.""" super().update() diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index cee864911b4..0ac26c1c76b 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -71,4 +71,4 @@ class SkybellSensor(SkybellDevice, SensorEntity): super().update() if self.entity_description.key == "chime_level": - self._attr_state = self._device.outdoor_chime_level + self._attr_native_value = self._device.outdoor_chime_level diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py index 8f5c17dad89..eec096e56c2 100644 --- a/homeassistant/components/sleepiq/sensor.py +++ b/homeassistant/components/sleepiq/sensor.py @@ -37,7 +37,7 @@ class SleepNumberSensor(SleepIQSensor, SensorEntity): self.update() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 6f3f7c2dca9..f0a10a5d5e1 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -21,7 +22,9 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, + POWER_WATT, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -32,7 +35,6 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.util import dt as dt_util from .const import ( CONF_CUSTOM, @@ -165,9 +167,11 @@ class SMAsensor(CoordinatorEntity, SensorEntity): self._device_info = device_info if self.unit_of_measurement == ENERGY_KILO_WATT_HOUR: - self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING self._attr_device_class = DEVICE_CLASS_ENERGY - self._attr_last_reset = dt_util.utc_from_timestamp(0) + if self.unit_of_measurement == POWER_WATT: + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_device_class = DEVICE_CLASS_POWER # Set sensor enabled to False. # Will be enabled by async_added_to_hass if actually used. @@ -179,12 +183,12 @@ class SMAsensor(CoordinatorEntity, SensorEntity): return self._sensor.name @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self._sensor.value @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._sensor.unit diff --git a/homeassistant/components/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index 1037d399e64..94c5bbcdcac 100644 --- a/homeassistant/components/smappee/__init__.py +++ b/homeassistant/components/smappee/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import api, config_flow @@ -37,7 +38,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Smappee component.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index fb00886f1f6..ec93501a508 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -1,8 +1,15 @@ """Support for monitoring a Smappee energy sensor.""" -from homeassistant.components.sensor import SensorEntity +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_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, POWER_WATT, ) @@ -28,34 +35,34 @@ TREND_SENSORS = { ], "power_today": [ "Total consumption - Today", - "mdi:power-plug", + None, ENERGY_WATT_HOUR, "power_today", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], "power_current_hour": [ "Total consumption - Current hour", - "mdi:power-plug", + None, ENERGY_WATT_HOUR, "power_current_hour", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], "power_last_5_minutes": [ "Total consumption - Last 5 minutes", - "mdi:power-plug", + None, ENERGY_WATT_HOUR, "power_last_5_minutes", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], "alwayson_today": [ "Always on - Today", - "mdi:sleep", + None, ENERGY_WATT_HOUR, "alwayson_today", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], } @@ -79,68 +86,68 @@ SOLAR_SENSORS = { ], "solar_today": [ "Total production - Today", - "mdi:white-balance-sunny", + None, ENERGY_WATT_HOUR, "solar_today", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], "solar_current_hour": [ "Total production - Current hour", - "mdi:white-balance-sunny", + None, ENERGY_WATT_HOUR, "solar_current_hour", - None, + DEVICE_CLASS_ENERGY, False, # cloud only ], } VOLTAGE_SENSORS = { "phase_voltages_a": [ "Phase voltages - A", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "phase_voltage_a", - None, + DEVICE_CLASS_VOLTAGE, ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], ], "phase_voltages_b": [ "Phase voltages - B", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "phase_voltage_b", - None, + DEVICE_CLASS_VOLTAGE, ["TWO", "THREE_STAR", "THREE_DELTA"], ], "phase_voltages_c": [ "Phase voltages - C", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "phase_voltage_c", - None, + DEVICE_CLASS_VOLTAGE, ["THREE_STAR"], ], "line_voltages_a": [ "Line voltages - A", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "line_voltage_a", - None, + DEVICE_CLASS_VOLTAGE, ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], ], "line_voltages_b": [ "Line voltages - B", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "line_voltage_b", - None, + DEVICE_CLASS_VOLTAGE, ["TWO", "THREE_STAR", "THREE_DELTA"], ], "line_voltages_c": [ "Line voltages - C", - "mdi:flash", + None, ELECTRIC_POTENTIAL_VOLT, "line_voltage_c", - None, + DEVICE_CLASS_VOLTAGE, ["THREE_STAR", "THREE_DELTA"], ], } @@ -246,6 +253,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) ) + # 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 + ], + ) + ) + async_add_entities(entities, True) @@ -268,7 +294,7 @@ class SmappeeSensor(SensorEntity): @property def name(self): """Return the name for this sensor.""" - if self._sensor in ["sensor", "load"]: + if self._sensor in ("sensor", "load", "switch"): return ( f"{self._service_location.service_location_name} - " f"{self._sensor.title()} - {self._name}" @@ -282,7 +308,7 @@ class SmappeeSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -292,7 +318,25 @@ class SmappeeSensor(SensorEntity): return self._device_class @property - def unit_of_measurement(self): + 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 @@ -301,7 +345,7 @@ class SmappeeSensor(SensorEntity): self, ): """Return the unique ID for this sensor.""" - if self._sensor in ["load", "sensor"]: + if self._sensor in ("load", "sensor", "switch"): return ( f"{self._service_location.device_serial_number}-" f"{self._service_location.service_location_id}-" @@ -337,11 +381,11 @@ class SmappeeSensor(SensorEntity): self._state = self._service_location.solar_power elif self._sensor == "alwayson": self._state = self._service_location.alwayson - elif self._sensor in [ + elif self._sensor 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": @@ -350,7 +394,7 @@ class SmappeeSensor(SensorEntity): 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"]: + elif self._sensor 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": @@ -359,14 +403,14 @@ class SmappeeSensor(SensorEntity): self._state = line_voltages[1] elif self._sensor == "line_voltages_c": self._state = line_voltages[2] - elif self._sensor in [ + elif self._sensor in ( "power_today", "power_current_hour", "power_last_5_minutes", "solar_today", "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": @@ -379,3 +423,9 @@ class SmappeeSensor(SensorEntity): for channel in sensor.channels: if channel.get("channel") == int(channel_id): self._state = channel.get("value_today") + elif self._sensor == "switch": + cons = self._service_location.actuators.get( + self._sensor_id + ).consumption_today + if cons is not None: + self._state = round(cons / 1000.0, 2) diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 46322f413e9..ded898f9f10 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -14,7 +14,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for service_location in smappee_base.smappee.service_locations.values(): for actuator_id, actuator in service_location.actuators.items(): - if actuator.type in ["SWITCH", "COMFORT_PLUG"]: + if actuator.type in ("SWITCH", "COMFORT_PLUG"): entities.append( SmappeeActuator( smappee_base, @@ -102,7 +102,7 @@ class SmappeeActuator(SwitchEntity): def turn_on(self, **kwargs): """Turn on Comport Plug.""" - if self._actuator_type in ["SWITCH", "COMFORT_PLUG"]: + if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): self._service_location.set_actuator_state(self._actuator_id, state="ON_ON") elif self._actuator_type == "INFINITY_OUTPUT_MODULE": self._service_location.set_actuator_state( @@ -111,7 +111,7 @@ class SmappeeActuator(SwitchEntity): def turn_off(self, **kwargs): """Turn off Comport Plug.""" - if self._actuator_type in ["SWITCH", "COMFORT_PLUG"]: + if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): self._service_location.set_actuator_state( self._actuator_id, state="OFF_OFF" ) @@ -128,17 +128,6 @@ class SmappeeActuator(SwitchEntity): or self._actuator_type == "COMFORT_PLUG" ) - @property - def today_energy_kwh(self): - """Return the today total energy usage in kWh.""" - if self._actuator_type == "SWITCH": - cons = self._service_location.actuators.get( - self._actuator_id - ).consumption_today - if cons is not None: - return round(cons / 1000.0, 2) - return None - @property def unique_id( self, diff --git a/homeassistant/components/smappee/translations/ca.json b/homeassistant/components/smappee/translations/ca.json index a630c81e0a7..71337e6a44d 100644 --- a/homeassistant/components/smappee/translations/ca.json +++ b/homeassistant/components/smappee/translations/ca.json @@ -15,7 +15,7 @@ "data": { "environment": "Entorn" }, - "description": "Configura el teu Smappee per a integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 de Smappee amb Home Assistant." }, "local": { "data": { diff --git a/homeassistant/components/smappee/translations/en_GB.json b/homeassistant/components/smappee/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/smappee/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smappee/translations/hu.json b/homeassistant/components/smappee/translations/hu.json index 5d3e65bb6fc..5b00dffde9c 100644 --- a/homeassistant/components/smappee/translations/hu.json +++ b/homeassistant/components/smappee/translations/hu.json @@ -2,8 +2,10 @@ "config": { "abort": { "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_configured_local_device": "A helyi eszk\u00f6z\u00f6k m\u00e1r konfigur\u00e1lva vannak. K\u00e9rj\u00fck, el\u0151sz\u00f6r t\u00e1vol\u00edtsa el ezeket, miel\u0151tt konfigur\u00e1lja a felh\u0151alap\u00fa eszk\u00f6zt.", "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.", "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." }, @@ -12,15 +14,21 @@ "environment": { "data": { "environment": "K\u00f6rnyezet" - } + }, + "description": "\u00c1ll\u00edtsa be a Smappee k\u00e9sz\u00fcl\u00e9ket az HomeAssistant-al val\u00f3 integr\u00e1ci\u00f3hoz." }, "local": { "data": { "host": "Hoszt" - } + }, + "description": "Adja meg a gazdag\u00e9pet a Smappee helyi integr\u00e1ci\u00f3j\u00e1nak elind\u00edt\u00e1s\u00e1hoz" }, "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" + }, + "zeroconf_confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni a \"{serialnumber} serialnumber}\" sorozatsz\u00e1m\u00fa Smappee -eszk\u00f6zt az HomeAssistanthoz?", + "title": "Felfedezett Smappee eszk\u00f6z" } } } diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index 3e88221851b..16379dca8cb 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -1,8 +1,9 @@ """The Smart Meter Texas integration.""" import asyncio import logging +import ssl -from smart_meter_texas import Account, Client +from smart_meter_texas import Account, Client, ClientSSLContext from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -39,7 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: password = entry.data[CONF_PASSWORD] account = Account(username, password) - smart_meter_texas_data = SmartMeterTexasData(hass, entry, account) + + client_ssl_context = ClientSSLContext() + ssl_context = await client_ssl_context.get_ssl_context() + + smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context) try: await smart_meter_texas_data.client.authenticate() except SmartMeterTexasAuthError: @@ -87,14 +92,18 @@ class SmartMeterTexasData: """Manages coordinatation of API data updates.""" def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, account: Account + self, + hass: HomeAssistant, + entry: ConfigEntry, + account: Account, + ssl_context: ssl.SSLContext, ) -> None: """Initialize the data coordintator.""" self._entry = entry self.account = account websession = aiohttp_client.async_get_clientsession(hass) - self.client = Client(websession, account) - self.meters = [] + self.client = Client(websession, account, ssl_context=ssl_context) + self.meters: list = [] async def setup(self): """Fetch all of the user's meters.""" diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index 296040d85f0..53428131e17 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -3,7 +3,7 @@ import asyncio import logging from aiohttp import ClientError -from smart_meter_texas import Account, Client +from smart_meter_texas import Account, Client, ClientSSLContext from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -28,10 +28,11 @@ async def validate_input(hass: core.HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - + client_ssl_context = ClientSSLContext() + ssl_context = await client_ssl_context.get_ssl_context() client_session = aiohttp_client.async_get_clientsession(hass) account = Account(data["username"], data["password"]) - client = Client(client_session, account) + client = Client(client_session, account, ssl_context) try: await client.authenticate() diff --git a/homeassistant/components/smart_meter_texas/manifest.json b/homeassistant/components/smart_meter_texas/manifest.json index 0e8a6b91236..f70cf59b9b9 100644 --- a/homeassistant/components/smart_meter_texas/manifest.json +++ b/homeassistant/components/smart_meter_texas/manifest.json @@ -3,7 +3,7 @@ "name": "Smart Meter Texas", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smart_meter_texas", - "requirements": ["smart-meter-texas==0.4.0"], + "requirements": ["smart-meter-texas==0.4.7"], "codeowners": ["@grahamwetzler"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index f63edcce0fc..6914d3ef1ac 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -33,7 +33,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of an Smart Meter Texas sensor.""" - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: """Initialize the sensor.""" @@ -58,7 +58,7 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): return self._available @property - def state(self): + def native_value(self): """Get the latest reading.""" return self._state diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py index ec4d2c9cad6..06d4de36b3c 100644 --- a/homeassistant/components/smarthab/__init__.py +++ b/homeassistant/components/smarthab/__init__.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType DOMAIN = "smarthab" DATA_HUB = "hub" @@ -32,7 +33,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SmartHab platform.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/smarthab/translations/hu.json b/homeassistant/components/smarthab/translations/hu.json index 222c95bba16..2e3cf430a9f 100644 --- a/homeassistant/components/smarthab/translations/hu.json +++ b/homeassistant/components/smarthab/translations/hu.json @@ -2,6 +2,7 @@ "config": { "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "service": "Hiba t\u00f6rt\u00e9nt a SmartHab el\u00e9r\u00e9se k\u00f6zben. A szolg\u00e1ltat\u00e1s le\u00e1llhat. Ellen\u0151rizze a kapcsolatot.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { @@ -10,7 +11,8 @@ "email": "E-mail", "password": "Jelsz\u00f3" }, - "description": "Technikai okokb\u00f3l ne felejtsen el m\u00e1sodlagos fi\u00f3kot haszn\u00e1lni a Home Assistant be\u00e1ll\u00edt\u00e1s\u00e1hoz. A SmartHab alkalmaz\u00e1sb\u00f3l l\u00e9trehozhat egyet." + "description": "Technikai okokb\u00f3l ne felejtsen el m\u00e1sodlagos fi\u00f3kot haszn\u00e1lni a Home Assistant be\u00e1ll\u00edt\u00e1s\u00e1hoz. A SmartHab alkalmaz\u00e1sb\u00f3l l\u00e9trehozhat egyet.", + "title": "A SmartHab be\u00e1ll\u00edt\u00e1sa" } } } diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bc64b173f20..fef2917fb8d 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -57,7 +57,7 @@ from .smartapp import ( _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the SmartThings platform.""" await setup_smartapp_endpoint(hass) return True diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index cb8fa4bb6d2..f5ab5562229 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -3,12 +3,15 @@ from __future__ import annotations from collections import namedtuple from collections.abc import Sequence -from datetime import datetime from pysmartthings import Attribute, Capability from pysmartthings.device import DeviceEntity -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( AREA_SQUARE_METERS, CONCENTRATION_PARTS_PER_MILLION, @@ -33,7 +36,6 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, VOLUME_CUBIC_METERS, ) -from homeassistant.util.dt import utc_from_timestamp from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN @@ -133,7 +135,7 @@ CAPABILITY_TO_SENSORS = { "Energy Meter", ENERGY_KILO_WATT_HOUR, DEVICE_CLASS_ENERGY, - STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) ], Capability.equivalent_carbon_dioxide_measurement: [ @@ -492,7 +494,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): return f"{self._device.device_id}.{self._attribute}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._device.status.attributes[self._attribute].value @@ -502,18 +504,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" unit = self._device.status.attributes[self._attribute].unit return UNITS.get(unit, unit) if unit else self._default_unit - @property - def last_reset(self) -> datetime | None: - """Return the time when the sensor was last reset, if any.""" - if self._attribute == Attribute.energy: - return utc_from_timestamp(0) - return None - class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Three Axis Sensor.""" @@ -534,7 +529,7 @@ class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): return f"{self._device.device_id}.{THREE_AXIS_NAMES[self._index]}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" three_axis = self._device.status.attributes[Attribute.three_axis].value try: @@ -554,8 +549,9 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): """Init the class.""" super().__init__(device) self.report_name = report_name - # This is an exception for STATE_CLASS_MEASUREMENT per @balloob self._attr_state_class = STATE_CLASS_MEASUREMENT + if self.report_name != "power": + self._attr_state_class = STATE_CLASS_TOTAL_INCREASING @property def name(self) -> str: @@ -565,10 +561,10 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): @property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self._device.device_id}.{self.report_name}" + return f"{self._device.device_id}.{self.report_name}_meter" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" value = self._device.status.attributes[Attribute.power_consumption].value if value is None or value.get(self.report_name) is None: @@ -585,15 +581,8 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): return DEVICE_CLASS_ENERGY @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" if self.report_name == "power": return POWER_WATT return ENERGY_KILO_WATT_HOUR - - @property - def last_reset(self) -> datetime | None: - """Return the time when the sensor was last reset, if any.""" - if self.report_name != "power": - return utc_from_timestamp(0) - return None diff --git a/homeassistant/components/smartthings/translations/en_GB.json b/homeassistant/components/smartthings/translations/en_GB.json new file mode 100644 index 00000000000..129d9e2ed29 --- /dev/null +++ b/homeassistant/components/smartthings/translations/en_GB.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "token_unauthorized": "The token is invalid or no longer authorised." + }, + "step": { + "authorize": { + "title": "Authorise Home Assistant" + }, + "select_location": { + "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorise installation of the Home Assistant integration into the selected location." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/translations/hu.json b/homeassistant/components/smartthings/translations/hu.json index bd6808db322..05e99bef2ea 100644 --- a/homeassistant/components/smartthings/translations/hu.json +++ b/homeassistant/components/smartthings/translations/hu.json @@ -1,5 +1,9 @@ { "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.", + "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.", "token_forbidden": "A token nem rendelkezik a sz\u00fcks\u00e9ges OAuth-tartom\u00e1nyokkal.", @@ -8,16 +12,22 @@ "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." }, "step": { + "authorize": { + "title": "HomeAssistant enged\u00e9lyez\u00e9se" + }, "pat": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token" }, - "description": "K\u00e9rj\u00fck, adjon meg egy SmartThings [Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si tokent]({token_url}), amelyet az [utas\u00edt\u00e1sok]({component_url}) alapj\u00e1n hoztak l\u00e9tre. Ezt haszn\u00e1ljuk a Home Assistant integr\u00e1ci\u00f3j\u00e1nak l\u00e9trehoz\u00e1s\u00e1hoz a SmartThings-fi\u00f3kban." + "description": "K\u00e9rj\u00fck, adjon meg egy SmartThings [Szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si tokent]({token_url}), amelyet az [utas\u00edt\u00e1sok]({component_url}) alapj\u00e1n hoztak l\u00e9tre. Ezt haszn\u00e1ljuk a Home Assistant integr\u00e1ci\u00f3j\u00e1nak l\u00e9trehoz\u00e1s\u00e1hoz a SmartThings-fi\u00f3kban.", + "title": "Adja meg a szem\u00e9lyes hozz\u00e1f\u00e9r\u00e9si Tokent" }, "select_location": { "data": { "location_id": "Elhelyezked\u00e9s" - } + }, + "description": "K\u00e9rj\u00fck, v\u00e1lassza ki azt a SmartThings helyet, amelyet hozz\u00e1 szeretne adni a Home Assistant szolg\u00e1ltat\u00e1shoz. Ezut\u00e1n \u00faj ablakot nyitunk, \u00e9s megk\u00e9rj\u00fck, hogy jelentkezzen be, \u00e9s enged\u00e9lyezze a Home Assistant integr\u00e1ci\u00f3j\u00e1nak telep\u00edt\u00e9s\u00e9t a kiv\u00e1lasztott helyre.", + "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.", diff --git a/homeassistant/components/smartthings/translations/zh-Hans.json b/homeassistant/components/smartthings/translations/zh-Hans.json index 849d69d55e5..3db5d7f0354 100644 --- a/homeassistant/components/smartthings/translations/zh-Hans.json +++ b/homeassistant/components/smartthings/translations/zh-Hans.json @@ -8,6 +8,11 @@ "webhook_error": "SmartThings \u65e0\u6cd5\u9a8c\u8bc1 `base_url` \u4e2d\u914d\u7f6e\u7684\u7aef\u70b9\u3002\u8bf7\u67e5\u770b\u7ec4\u4ef6\u9700\u6c42\u3002" }, "step": { + "pat": { + "data": { + "access_token": "\u8bbf\u95ee\u4ee4\u724c" + } + }, "user": { "description": "\u8bf7\u8f93\u5165\u6309\u7167[\u8bf4\u660e]({component_url})\u521b\u5efa\u7684 SmartThings [\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c]({token_url})\u3002", "title": "\u8f93\u5165\u4e2a\u4eba\u8bbf\u95ee\u4ee4\u724c" diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 95a862502cd..9922792ba12 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -87,7 +87,7 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" @property - def state(self) -> str: + def native_value(self) -> str: """Return the current state of the sensor.""" if isinstance(self._state, Enum): return self._state.name.lower() @@ -109,7 +109,7 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor): return self._state @property - def state(self) -> str: + def native_value(self) -> str: """Return the current state of the sensor.""" return self.cycle.status.name.lower() @@ -147,7 +147,7 @@ class SmartTubSecondaryFiltrationCycle(SmartTubSensor): return self._state @property - def state(self) -> str: + def native_value(self) -> str: """Return the current state of the sensor.""" return self.cycle.status.name.lower() diff --git a/homeassistant/components/smarttub/translations/zh-Hans.json b/homeassistant/components/smarttub/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/smarttub/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index b958185f9bd..44a8392991a 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -1,4 +1,5 @@ """Support for Salda Smarty XP/XV Ventilation Unit Sensors.""" +from __future__ import annotations import datetime as dt import logging @@ -43,7 +44,7 @@ class SmartySensor(SensorEntity): ): """Initialize the entity.""" self._name = name - self._state = None + self._state: dt.datetime | None = None self._sensor_type = device_class self._unit_of_measurement = unit_of_measurement self._smarty = smarty @@ -64,12 +65,12 @@ class SmartySensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index fc2310426e3..d405c817656 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -3,7 +3,7 @@ import logging import gammu # pylint: disable=import-error -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import DEVICE_CLASS_SIGNAL_STRENGTH, SIGNAL_STRENGTH_DECIBELS from .const import DOMAIN, SMS_GATEWAY @@ -14,48 +14,40 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the GSM Signal Sensor sensor.""" gateway = hass.data[DOMAIN][SMS_GATEWAY] - entities = [] imei = await gateway.get_imei_async() - name = f"gsm_signal_imei_{imei}" - entities.append( - GSMSignalSensor( - hass, - gateway, - name, - ) + async_add_entities( + [ + GSMSignalSensor( + hass, + gateway, + imei, + SensorEntityDescription( + key="signal", + name=f"gsm_signal_imei_{imei}", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, + ), + ) + ], + True, ) - async_add_entities(entities, True) class GSMSignalSensor(SensorEntity): """Implementation of a GSM Signal sensor.""" - def __init__( - self, - hass, - gateway, - name, - ): + def __init__(self, hass, gateway, imei, description): """Initialize the GSM Signal sensor.""" + self._attr_device_info = { + "identifiers": {(DOMAIN, str(imei))}, + "name": "SMS Gateway", + } + self._attr_unique_id = str(imei) self._hass = hass self._gateway = gateway - self._name = name self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SIGNAL_STRENGTH_DECIBELS - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEVICE_CLASS_SIGNAL_STRENGTH + self.entity_description = description @property def available(self): @@ -63,7 +55,7 @@ class GSMSignalSensor(SensorEntity): return self._state is not None @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state["SignalStrength"] @@ -78,8 +70,3 @@ class GSMSignalSensor(SensorEntity): def extra_state_attributes(self): """Return the sensor attributes.""" return self._state - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 7de2bfb91e2..09bfe3856cc 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -155,12 +155,12 @@ class SnmpSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/sochain/sensor.py b/homeassistant/components/sochain/sensor.py index 1f735da4995..a4cdd595f90 100644 --- a/homeassistant/components/sochain/sensor.py +++ b/homeassistant/components/sochain/sensor.py @@ -54,7 +54,7 @@ class SochainSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return ( self.chainso.data.get("confirmed_balance") @@ -63,7 +63,7 @@ class SochainSensor(SensorEntity): ) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 872781bf19c..c9c7136fb94 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -2,7 +2,10 @@ from datetime import timedelta import logging -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -10,7 +13,6 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) -from homeassistant.util import dt as dt_util from .models import SolarEdgeSensorEntityDescription @@ -40,9 +42,8 @@ SENSOR_TYPES = [ json_key="lifeTimeData", name="Lifetime energy", icon="mdi:solar-power", - last_reset=dt_util.utc_from_timestamp(0), - state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), SolarEdgeSensorEntityDescription( @@ -51,7 +52,7 @@ SENSOR_TYPES = [ name="Energy this year", entity_registry_enabled_default=False, icon="mdi:solar-power", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), SolarEdgeSensorEntityDescription( @@ -60,7 +61,7 @@ SENSOR_TYPES = [ name="Energy this month", entity_registry_enabled_default=False, icon="mdi:solar-power", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), SolarEdgeSensorEntityDescription( @@ -69,7 +70,7 @@ SENSOR_TYPES = [ name="Energy today", entity_registry_enabled_default=False, icon="mdi:solar-power", - unit_of_measurement=ENERGY_WATT_HOUR, + native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, ), SolarEdgeSensorEntityDescription( @@ -78,7 +79,7 @@ SENSOR_TYPES = [ name="Current Power", icon="mdi:solar-power", state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), SolarEdgeSensorEntityDescription( @@ -185,6 +186,6 @@ SENSOR_TYPES = [ name="Storage Level", entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), ] diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 85e01a2d7ee..23aa269cf36 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -133,7 +133,7 @@ class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API overview sensor.""" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) @@ -147,7 +147,7 @@ class SolarEdgeDetailsSensor(SolarEdgeSensorEntity): return self.data_service.attributes @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data @@ -161,7 +161,7 @@ class SolarEdgeInventorySensor(SolarEdgeSensorEntity): return self.data_service.attributes.get(self.entity_description.json_key) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) @@ -173,7 +173,7 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): """Initialize the power flow sensor.""" super().__init__(platform_name, sensor_type, data_service) - self._attr_unit_of_measurement = data_service.unit + self._attr_native_unit_of_measurement = data_service.unit @property def extra_state_attributes(self) -> dict[str, Any]: @@ -181,7 +181,7 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): return self.data_service.attributes.get(self.entity_description.json_key) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) @@ -200,7 +200,7 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): """Initialize the power flow sensor.""" super().__init__(platform_name, description, data_service) - self._attr_unit_of_measurement = data_service.unit + self._attr_native_unit_of_measurement = data_service.unit @property def extra_state_attributes(self) -> dict[str, Any]: @@ -208,7 +208,7 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): return self.data_service.attributes.get(self.entity_description.json_key) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) @@ -219,7 +219,7 @@ class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" attr = self.data_service.attributes.get(self.entity_description.json_key) if attr and "soc" in attr: diff --git a/homeassistant/components/solaredge/translations/fr.json b/homeassistant/components/solaredge/translations/fr.json index fb1822f9a40..638e19a2a03 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\u00e9ja configur\u00e9" }, "error": { - "already_configured": "L'appareil est d\u00e9ja configur\u00e9 ", + "already_configured": "L'appareil est d\u00e9ja 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/translations/hu.json b/homeassistant/components/solaredge/translations/hu.json index 69e450f55ff..a1a14c76357 100644 --- a/homeassistant/components/solaredge/translations/hu.json +++ b/homeassistant/components/solaredge/translations/hu.json @@ -13,7 +13,8 @@ "user": { "data": { "api_key": "API kulcs", - "name": "Ennek az install\u00e1ci\u00f3nak a neve" + "name": "Ennek az install\u00e1ci\u00f3nak a neve", + "site_id": "A SolarEdge webhelyazonos\u00edt\u00f3ja" }, "title": "Az API param\u00e9terek megad\u00e1sa ehhez a telep\u00edt\u00e9shez" } diff --git a/homeassistant/components/solaredge/translations/zh-Hans.json b/homeassistant/components/solaredge/translations/zh-Hans.json index baf8c980cb7..7f5039e9f93 100644 --- a/homeassistant/components/solaredge/translations/zh-Hans.json +++ b/homeassistant/components/solaredge/translations/zh-Hans.json @@ -1,10 +1,22 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "could_not_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 SolarEdge API", + "invalid_api_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5", + "site_not_active": "\u672a\u6fc0\u6d3b" + }, "step": { "user": { "data": { - "api_key": "API \u5bc6\u7801" - } + "api_key": "API \u5bc6\u7801", + "name": "\u5b89\u88c5\u540d\u79f0", + "site_id": "SolarEdge \u7ad9\u70b9 ID" + }, + "title": "\u5b9a\u4e49\u672c\u6b21\u5b89\u88c5\u7684 API \u53c2\u6570" } } } diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 3f159ce4480..9d162e919f4 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -50,6 +50,7 @@ SENSOR_TYPES = { ELECTRIC_POTENTIAL_VOLT, "mdi:current-ac", None, + None, ], "current_DC_voltage": [ "dcvoltage", @@ -57,6 +58,7 @@ SENSOR_TYPES = { ELECTRIC_POTENTIAL_VOLT, "mdi:current-dc", None, + None, ], "current_frequency": [ "gridfrequency", @@ -285,7 +287,7 @@ class SolarEdgeSensor(SensorEntity): return f"{self._platform_name} ({self._name})" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement @@ -305,7 +307,7 @@ class SolarEdgeSensor(SensorEntity): return self._icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index b3cfebe9abc..e32f1d85564 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,12 +1,28 @@ """Solar-Log integration.""" +from datetime import timedelta +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for solarlog.""" + coordinator = SolarlogData(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -14,3 +30,73 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass, entry): """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SolarlogData(update_coordinator.DataUpdateCoordinator): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + ) + + host_entry = entry.data[CONF_HOST] + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + self.unique_id = entry.entry_id + self.name = entry.title + self.host = url.geturl() + + 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) + except (OSError, Timeout, HTTPError) as err: + raise update_coordinator.UpdateFailed(err) + + if api.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 = {} + + 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/config_flow.py b/homeassistant/components/solarlog/config_flow.py index cced913222a..4267502e3ca 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -31,7 +31,7 @@ class SolarLogConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._errors = {} + self._errors: dict = {} def _host_in_configuration_exists(self, host) -> bool: """Return True if host exists in configuration.""" diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index 0d989642d07..eecf73b6a09 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -1,7 +1,19 @@ """Constants for the Solar-Log integration.""" -from datetime import timedelta +from __future__ import annotations +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntityDescription, +) from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, PERCENTAGE, @@ -10,90 +22,193 @@ from homeassistant.const import ( DOMAIN = "solarlog" -"""Default config for solarlog.""" +# Default config for solarlog. DEFAULT_HOST = "http://solar-log" DEFAULT_NAME = "solarlog" -"""Fixed constants.""" -SCAN_INTERVAL = timedelta(seconds=60) -"""Supported sensor types.""" -SENSOR_TYPES = { - "time": ["TIME", "last update", None, "mdi:calendar-clock"], - "power_ac": ["powerAC", "power AC", POWER_WATT, "mdi:solar-power"], - "power_dc": ["powerDC", "power DC", POWER_WATT, "mdi:solar-power"], - "voltage_ac": ["voltageAC", "voltage AC", ELECTRIC_POTENTIAL_VOLT, "mdi:flash"], - "voltage_dc": ["voltageDC", "voltage DC", ELECTRIC_POTENTIAL_VOLT, "mdi:flash"], - "yield_day": ["yieldDAY", "yield day", ENERGY_KILO_WATT_HOUR, "mdi:solar-power"], - "yield_yesterday": [ - "yieldYESTERDAY", - "yield yesterday", - ENERGY_KILO_WATT_HOUR, - "mdi:solar-power", - ], - "yield_month": [ - "yieldMONTH", - "yield month", - ENERGY_KILO_WATT_HOUR, - "mdi:solar-power", - ], - "yield_year": ["yieldYEAR", "yield year", ENERGY_KILO_WATT_HOUR, "mdi:solar-power"], - "yield_total": [ - "yieldTOTAL", - "yield total", - ENERGY_KILO_WATT_HOUR, - "mdi:solar-power", - ], - "consumption_ac": ["consumptionAC", "consumption AC", POWER_WATT, "mdi:power-plug"], - "consumption_day": [ - "consumptionDAY", - "consumption day", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "consumption_yesterday": [ - "consumptionYESTERDAY", - "consumption yesterday", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "consumption_month": [ - "consumptionMONTH", - "consumption month", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "consumption_year": [ - "consumptionYEAR", - "consumption year", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "consumption_total": [ - "consumptionTOTAL", - "consumption total", - ENERGY_KILO_WATT_HOUR, - "mdi:power-plug", - ], - "total_power": ["totalPOWER", "total power", "Wp", "mdi:solar-power"], - "alternator_loss": [ - "alternatorLOSS", - "alternator loss", - POWER_WATT, - "mdi:solar-power", - ], - "capacity": ["CAPACITY", "capacity", PERCENTAGE, "mdi:solar-power"], - "efficiency": [ - "EFFICIENCY", - "efficiency", - f"% {POWER_WATT}/{POWER_WATT}p", - "mdi:solar-power", - ], - "power_available": [ - "powerAVAILABLE", - "power available", - POWER_WATT, - "mdi:solar-power", - ], - "usage": ["USAGE", "usage", None, "mdi:solar-power"], -} +@dataclass +class SolarlogRequiredKeysMixin: + """Mixin for required keys.""" + + json_key: str + + +@dataclass +class SolarLogSensorEntityDescription( + SensorEntityDescription, SolarlogRequiredKeysMixin +): + """Describes Solarlog sensor entity.""" + + +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, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="power_dc", + json_key="powerDC", + name="power DC", + icon="mdi:solar-power", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="voltage_ac", + json_key="voltageAC", + name="voltage AC", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="voltage_dc", + json_key="voltageDC", + name="voltage DC", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="yield_day", + json_key="yieldDAY", + name="yield day", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SolarLogSensorEntityDescription( + key="yield_yesterday", + json_key="yieldYESTERDAY", + name="yield yesterday", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SolarLogSensorEntityDescription( + key="yield_month", + json_key="yieldMONTH", + name="yield month", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + SolarLogSensorEntityDescription( + key="yield_year", + json_key="yieldYEAR", + name="yield year", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + 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, + ), + SolarLogSensorEntityDescription( + key="consumption_ac", + json_key="consumptionAC", + name="consumption AC", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="consumption_day", + json_key="consumptionDAY", + name="consumption day", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + SolarLogSensorEntityDescription( + key="consumption_yesterday", + json_key="consumptionYESTERDAY", + name="consumption yesterday", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + SolarLogSensorEntityDescription( + key="consumption_month", + json_key="consumptionMONTH", + name="consumption month", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + SolarLogSensorEntityDescription( + key="consumption_year", + json_key="consumptionYEAR", + name="consumption year", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + ), + 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, + ), + SolarLogSensorEntityDescription( + key="total_power", + json_key="totalPOWER", + name="installed peak power", + icon="mdi:solar-power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + ), + SolarLogSensorEntityDescription( + key="alternator_loss", + json_key="alternatorLOSS", + name="alternator loss", + icon="mdi:solar-power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + 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, + ), + SolarLogSensorEntityDescription( + key="efficiency", + json_key="EFFICIENCY", + name="efficiency", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="power_available", + json_key="powerAVAILABLE", + name="power available", + icon="mdi:solar-power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="usage", + json_key="USAGE", + name="usage", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), +) diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 85a1531090d..ee7425cf2d7 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -1,163 +1,42 @@ """Platform for solarlog sensors.""" -import logging -from urllib.parse import ParseResult, urlparse - -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog - from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.entity import StateType -from .const import DOMAIN, SCAN_INTERVAL, SENSOR_TYPES - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the solarlog platform.""" - _LOGGER.warning( - "Configuration of the solarlog platform in configuration.yaml is deprecated " - "in Home Assistant 0.119. Please remove entry from your configuration" - ) +from . import SolarlogData +from .const import DOMAIN, SENSOR_TYPES, SolarLogSensorEntityDescription async def async_setup_entry(hass, entry, async_add_entities): """Add solarlog entry.""" - host_entry = entry.data[CONF_HOST] - device_name = entry.title - - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) - host = url.geturl() - - try: - api = await hass.async_add_executor_job(SolarLog, host) - _LOGGER.debug("Connected to Solar-Log device, setting up entries") - except (OSError, HTTPError, Timeout): - _LOGGER.error( - "Could not connect to Solar-Log device at %s, check host ip address", host - ) - return - - # Create solarlog data service which will retrieve and update the data. - data = await hass.async_add_executor_job(SolarlogData, hass, api, host) - - # Create a new sensor for each sensor type. - entities = [] - for sensor_key in SENSOR_TYPES: - sensor = SolarlogSensor(entry.entry_id, device_name, sensor_key, data) - entities.append(sensor) - - async_add_entities(entities, True) - return True + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SolarlogSensor(coordinator, description) for description in SENSOR_TYPES + ) -class SolarlogSensor(SensorEntity): +class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity): """Representation of a Sensor.""" - def __init__(self, entry_id, device_name, sensor_key, data): + entity_description: SolarLogSensorEntityDescription + + def __init__( + self, + coordinator: SolarlogData, + description: SolarLogSensorEntityDescription, + ) -> None: """Initialize the sensor.""" - self.device_name = device_name - self.sensor_key = sensor_key - self.data = data - self.entry_id = entry_id - self._state = None - - self._json_key = SENSOR_TYPES[self.sensor_key][0] - self._label = SENSOR_TYPES[self.sensor_key][1] - self._unit_of_measurement = SENSOR_TYPES[self.sensor_key][2] - self._icon = SENSOR_TYPES[self.sensor_key][3] - - @property - def unique_id(self): - """Return the unique id.""" - return f"{self.entry_id}_{self.sensor_key}" - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.device_name} {self._label}" - - @property - def unit_of_measurement(self): - """Return the state of the sensor.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the sensor icon.""" - return self._icon - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_info(self): - """Return the device information.""" - return { - "identifiers": {(DOMAIN, self.entry_id)}, - "name": self.device_name, + super().__init__(coordinator) + self.entity_description = description + self._attr_name = f"{coordinator.name} {description.name}" + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self._attr_device_info = { + "identifiers": {(DOMAIN, coordinator.unique_id)}, + "name": coordinator.name, "manufacturer": "Solar-Log", } - 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] - - -class SolarlogData: - """Get and update the latest data.""" - - def __init__(self, hass, api, host): - """Initialize the data object.""" - self.api = api - self.hass = hass - self.host = host - self.update = Throttle(SCAN_INTERVAL)(self._update) - self.data = {} - - def _update(self): - """Update the data from the SolarLog device.""" - try: - self.api = SolarLog(self.host) - response = self.api.time - _LOGGER.debug( - "Connection to Solarlog successful. Retrieving latest Solarlog update of %s", - response, - ) - except (OSError, Timeout, HTTPError): - _LOGGER.error("Connection error, Could not retrieve data, skipping update") - return - - try: - self.data["TIME"] = self.api.time - self.data["powerAC"] = self.api.power_ac - self.data["powerDC"] = self.api.power_dc - self.data["voltageAC"] = self.api.voltage_ac - self.data["voltageDC"] = self.api.voltage_dc - self.data["yieldDAY"] = self.api.yield_day / 1000 - self.data["yieldYESTERDAY"] = self.api.yield_yesterday / 1000 - self.data["yieldMONTH"] = self.api.yield_month / 1000 - self.data["yieldYEAR"] = self.api.yield_year / 1000 - self.data["yieldTOTAL"] = self.api.yield_total / 1000 - self.data["consumptionAC"] = self.api.consumption_ac - self.data["consumptionDAY"] = self.api.consumption_day / 1000 - self.data["consumptionYESTERDAY"] = self.api.consumption_yesterday / 1000 - self.data["consumptionMONTH"] = self.api.consumption_month / 1000 - self.data["consumptionYEAR"] = self.api.consumption_year / 1000 - self.data["consumptionTOTAL"] = self.api.consumption_total / 1000 - self.data["totalPOWER"] = self.api.total_power - self.data["alternatorLOSS"] = self.api.alternator_loss - self.data["CAPACITY"] = round(self.api.capacity * 100, 0) - self.data["EFFICIENCY"] = round(self.api.efficiency * 100, 0) - self.data["powerAVAILABLE"] = self.api.power_available - self.data["USAGE"] = self.api.usage - _LOGGER.debug("Updated Solarlog overview data: %s", self.data) - except AttributeError: - _LOGGER.error("Missing details data in Solarlog response") + @property + def native_value(self) -> StateType: + """Return the native sensor value.""" + return self.coordinator.data[self.entity_description.json_key] diff --git a/homeassistant/components/solarlog/translations/fr.json b/homeassistant/components/solarlog/translations/fr.json index 3e950af8564..b327f58adf5 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, veuillez v\u00e9rifier l'adresse de l'h\u00f4te." + "cannot_connect": "\u00c9chec de la connexion" }, "step": { "user": { diff --git a/homeassistant/components/solarlog/translations/hu.json b/homeassistant/components/solarlog/translations/hu.json index dd0ea8033ae..23baa393942 100644 --- a/homeassistant/components/solarlog/translations/hu.json +++ b/homeassistant/components/solarlog/translations/hu.json @@ -10,8 +10,10 @@ "step": { "user": { "data": { - "host": "Hoszt" - } + "host": "Hoszt", + "name": "A Solar-Log szenzorokhoz haszn\u00e1land\u00f3 el\u0151tag" + }, + "title": "Hat\u00e1rozza meg a Solar-Log kapcsolatot" } } } diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index d14cfea2501..f6a6f581e12 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -2,7 +2,7 @@ "domain": "solax", "name": "SolaX Power", "documentation": "https://www.home-assistant.io/integrations/solax", - "requirements": ["solax==0.2.6"], + "requirements": ["solax==0.2.8"], "codeowners": ["@squishykid"], "iot_class": "local_polling" } diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index e47f5c57802..7854142c32b 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -6,8 +6,23 @@ from solax import real_time_api from solax.inverter import InverterError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, TEMP_CELSIUS +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PORT, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + TEMP_CELSIUS, +) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval @@ -34,10 +49,28 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_track_time_interval(hass, endpoint.async_refresh, SCAN_INTERVAL) devices = [] for sensor, (idx, unit) in api.inverter.sensor_map().items(): + device_class = state_class = None if unit == "C": + device_class = DEVICE_CLASS_TEMPERATURE + state_class = STATE_CLASS_MEASUREMENT unit = TEMP_CELSIUS + elif unit == "kWh": + device_class = DEVICE_CLASS_ENERGY + state_class = STATE_CLASS_TOTAL_INCREASING + elif unit == "V": + device_class = DEVICE_CLASS_VOLTAGE + state_class = STATE_CLASS_MEASUREMENT + elif unit == "A": + device_class = DEVICE_CLASS_CURRENT + state_class = STATE_CLASS_MEASUREMENT + elif unit == "W": + device_class = DEVICE_CLASS_POWER + state_class = STATE_CLASS_MEASUREMENT + elif unit == "%": + device_class = DEVICE_CLASS_BATTERY + state_class = STATE_CLASS_MEASUREMENT uid = f"{serial}-{idx}" - devices.append(Inverter(uid, serial, sensor, unit)) + devices.append(Inverter(uid, serial, sensor, unit, state_class, device_class)) endpoint.sensors = devices async_add_entities(devices) @@ -75,16 +108,26 @@ class RealTimeDataEndpoint: class Inverter(SensorEntity): """Class for a sensor.""" - def __init__(self, uid, serial, key, unit): + def __init__( + self, + uid, + serial, + key, + unit, + state_class=None, + device_class=None, + ): """Initialize an inverter sensor.""" self.uid = uid self.serial = serial self.key = key self.value = None self.unit = unit + self._attr_state_class = state_class + self._attr_device_class = device_class @property - def state(self): + def native_value(self): """State of this inverter attribute.""" return self.value @@ -99,7 +142,7 @@ class Inverter(SensorEntity): return f"Solax {self.serial} {self.key}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.unit diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 4df12c9f8f5..948e8d1e1e1 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -30,7 +30,7 @@ class SomaSensor(SomaEntity, SensorEntity): """Representation of a Soma cover device.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE @property def name(self): @@ -38,7 +38,7 @@ class SomaSensor(SomaEntity, SensorEntity): return self.device["name"] + " battery level" @property - def state(self): + def native_value(self): """Return the state of the entity.""" return self.battery_state diff --git a/homeassistant/components/soma/translations/en_GB.json b/homeassistant/components/soma/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/soma/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/soma/translations/hu.json b/homeassistant/components/soma/translations/hu.json index d013cb49fdf..c3e572ebe0a 100644 --- a/homeassistant/components/soma/translations/hu.json +++ b/homeassistant/components/soma/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_setup": "Csak egy Soma-fi\u00f3k konfigur\u00e1lhat\u00f3.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "connection_error": "Nem siker\u00fclt csatlakozni a SOMA Connecthez.", "missing_configuration": "A Soma \u00f6sszetev\u0151 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t.", diff --git a/homeassistant/components/soma/translations/zh-Hans.json b/homeassistant/components/soma/translations/zh-Hans.json new file mode 100644 index 00000000000..51fbc254b7f --- /dev/null +++ b/homeassistant/components/soma/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "connection_error": "\u65e0\u6cd5\u8fde\u63a5 SOMA Connect\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/climate.py b/homeassistant/components/somfy/climate.py index 0963321100c..d4461e91e1b 100644 --- a/homeassistant/components/somfy/climate.py +++ b/homeassistant/components/somfy/climate.py @@ -165,7 +165,7 @@ class SomfyClimate(SomfyEntity, ClimateEntity): temperature = self._climate.get_night_temperature() elif preset_mode == PRESET_FROST_GUARD: temperature = self._climate.get_frost_protection_temperature() - elif preset_mode in [PRESET_MANUAL, PRESET_GEOFENCING]: + elif preset_mode in (PRESET_MANUAL, PRESET_GEOFENCING): temperature = self.target_temperature else: raise ValueError(f"Preset mode not supported: {preset_mode}") diff --git a/homeassistant/components/somfy/sensor.py b/homeassistant/components/somfy/sensor.py index 1817ba3fd8c..9a0602cb592 100644 --- a/homeassistant/components/somfy/sensor.py +++ b/homeassistant/components/somfy/sensor.py @@ -30,7 +30,7 @@ class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity): """Representation of a Somfy thermostat battery.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, coordinator, device_id): """Initialize the Somfy device.""" @@ -43,6 +43,6 @@ class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity): self._climate = Thermostat(self.device, self.coordinator.client) @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self._climate.get_battery() diff --git a/homeassistant/components/somfy/translations/en_GB.json b/homeassistant/components/somfy/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/somfy/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/translations/hu.json b/homeassistant/components/somfy_mylink/translations/hu.json index 5a2a1ee6ab5..3610a930022 100644 --- a/homeassistant/components/somfy_mylink/translations/hu.json +++ b/homeassistant/components/somfy_mylink/translations/hu.json @@ -26,11 +26,15 @@ }, "step": { "entity_config": { + "data": { + "reverse": "A bor\u00edt\u00f3 megfordult" + }, "description": "Konfigur\u00e1lja az \u201e {entity_id} \u201d be\u00e1ll\u00edt\u00e1sait", "title": "Entit\u00e1s konfigur\u00e1l\u00e1sa" }, "init": { "data": { + "default_reverse": "A konfigur\u00e1latlan bor\u00edt\u00f3k alap\u00e9rtelmezett megford\u00edt\u00e1si \u00e1llapota", "entity_id": "Konfigur\u00e1ljon egy adott entit\u00e1st.", "target_id": "Az \u00e1rny\u00e9kol\u00f3 be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa." }, diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index db82e729483..cc35a8db4af 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -64,9 +64,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the flow.""" - self._reauth = False - self._entry_id = None - self._entry_data = {} + self.entry = None @staticmethod @callback @@ -76,10 +74,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle configuration by re-auth.""" - self._reauth = True - self._entry_data = dict(data) - entry = await self.async_set_unique_id(self.unique_id) - self._entry_id = entry.entry_id + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() @@ -90,7 +85,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="reauth_confirm", - description_placeholders={"host": self._entry_data[CONF_HOST]}, + description_placeholders={"host": self.entry.data[CONF_HOST]}, data_schema=vol.Schema({}), errors={}, ) @@ -104,8 +99,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - if self._reauth: - user_input = {**self._entry_data, **user_input} + if self.entry: + user_input = {**self.entry.data, **user_input} if CONF_VERIFY_SSL not in user_input: user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL @@ -120,10 +115,8 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: - if self._reauth: - return await self._async_reauth_update_entry( - self._entry_id, user_input - ) + if self.entry: + return await self._async_reauth_update_entry(user_input) return self.async_create_entry( title=user_input[CONF_HOST], data=user_input @@ -136,17 +129,16 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_reauth_update_entry(self, entry_id: str, data: dict) -> FlowResult: + async def _async_reauth_update_entry(self, data: dict) -> FlowResult: """Update existing config entry.""" - entry = self.hass.config_entries.async_get_entry(entry_id) - self.hass.config_entries.async_update_entry(entry, data=data) - await self.hass.config_entries.async_reload(entry.entry_id) + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") def _get_user_data_schema(self) -> dict[str, Any]: """Get the data schema to display user form.""" - if self._reauth: + if self.entry: return {vol.Required(CONF_API_KEY): str} data_schema = { diff --git a/homeassistant/components/sonarr/const.py b/homeassistant/components/sonarr/const.py index 45b26166c92..be0fa00d597 100644 --- a/homeassistant/components/sonarr/const.py +++ b/homeassistant/components/sonarr/const.py @@ -1,11 +1,6 @@ """Constants for Sonarr.""" DOMAIN = "sonarr" -# Attributes -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_SOFTWARE_VERSION = "sw_version" - # Config Keys CONF_BASE_PATH = "base_path" CONF_DAYS = "days" diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index 3fc74b1ddb5..d3f1b089d14 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -3,10 +3,15 @@ from __future__ import annotations from sonarr import Sonarr -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_NAME, + ATTR_SW_VERSION, +) from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_SOFTWARE_VERSION, DOMAIN +from .const import DOMAIN class SonarrEntity(Entity): @@ -34,6 +39,6 @@ class SonarrEntity(Entity): ATTR_IDENTIFIERS: {(DOMAIN, self._device_id)}, ATTR_NAME: "Activity Sensor", ATTR_MANUFACTURER: "Sonarr", - ATTR_SOFTWARE_VERSION: self.sonarr.app.info.version, + ATTR_SW_VERSION: self.sonarr.app.info.version, "entry_type": "service", } diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index d173d42eaf7..3f5ef275fef 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -85,7 +85,7 @@ class SonarrSensor(SonarrEntity, SensorEntity): self._attr_name = name self._attr_icon = icon self._attr_unique_id = f"{entry_id}_{key}" - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._attr_entity_registry_enabled_default = enabled_default self.last_update_success = False @@ -134,7 +134,7 @@ class SonarrCommandsSensor(SonarrSensor): return attrs @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return len(self._commands) @@ -181,7 +181,7 @@ class SonarrDiskspaceSensor(SonarrSensor): return attrs @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" free = self._total_free / 1024 ** 3 return f"{free:.2f}" @@ -223,7 +223,7 @@ class SonarrQueueSensor(SonarrSensor): return attrs @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return len(self._queue) @@ -261,7 +261,7 @@ class SonarrSeriesSensor(SonarrSensor): return attrs @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return len(self._items) @@ -304,7 +304,7 @@ class SonarrUpcomingSensor(SonarrSensor): return attrs @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return len(self._upcoming) @@ -347,6 +347,6 @@ class SonarrWantedSensor(SonarrSensor): return attrs @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._total diff --git a/homeassistant/components/sonarr/translations/zh-Hans.json b/homeassistant/components/sonarr/translations/zh-Hans.json new file mode 100644 index 00000000000..265928213f5 --- /dev/null +++ b/homeassistant/components/sonarr/translations/zh-Hans.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548" + }, + "flow_title": "{name}", + "step": { + "reauth_confirm": { + "description": "Sonarr \u96c6\u6210\u9700\u8981\u624b\u52a8\u91cd\u65b0\u9a8c\u8bc1\uff1a{host}" + }, + "user": { + "data": { + "api_key": "API \u5bc6\u94a5", + "host": "\u4e3b\u673a\u5730\u5740", + "port": "\u7aef\u53e3", + "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "\u663e\u793a\u5373\u5c06\u5185\u5bb9\u7684\u5929\u6570", + "wanted_max_items": "\u5185\u5bb9\u663e\u793a\u6700\u5927\u6570\u91cf" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/songpal/__init__.py b/homeassistant/components/songpal/__init__.py index b542591b294..2053d2857c2 100644 --- a/homeassistant/components/songpal/__init__.py +++ b/homeassistant/components/songpal/__init__.py @@ -1,5 +1,4 @@ """The songpal component.""" -from collections import OrderedDict import voluptuous as vol @@ -7,6 +6,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import CONF_ENDPOINT, DOMAIN @@ -22,7 +22,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["media_player"] -async def async_setup(hass: HomeAssistant, config: OrderedDict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up songpal environment.""" conf = config.get(DOMAIN) if conf is None: diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index f0219ea8cf0..ae3652683d4 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -138,6 +138,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a Sonos config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown() + hass.data.pop(DATA_SONOS) + hass.data.pop(DATA_SONOS_DISCOVERY_MANAGER) + return unload_ok + + class SonosDiscoveryManager: """Manage sonos discovery.""" @@ -151,6 +160,11 @@ class SonosDiscoveryManager: self.hosts = hosts self.discovery_lock = asyncio.Lock() + async def async_shutdown(self): + """Stop all running tasks.""" + await self._async_stop_event_listener() + self._stop_manual_heartbeat() + def _create_soco(self, ip_address: str, source: SoCoCreationSource) -> SoCo | None: """Create a soco instance and return if successful.""" if ip_address in self.data.discovery_ignored: @@ -171,15 +185,14 @@ class SonosDiscoveryManager: ) return None - async def _async_stop_event_listener(self, event: Event) -> None: + async def _async_stop_event_listener(self, event: Event | None = None) -> None: await asyncio.gather( - *(speaker.async_unsubscribe() for speaker in self.data.discovered.values()), - return_exceptions=True, + *(speaker.async_unsubscribe() for speaker in self.data.discovered.values()) ) if events_asyncio.event_listener: await events_asyncio.event_listener.async_stop() - def _stop_manual_heartbeat(self, event: Event) -> None: + def _stop_manual_heartbeat(self, event: Event | None = None) -> None: if self.data.hosts_heartbeat: self.data.hosts_heartbeat() self.data.hosts_heartbeat = None diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 88b71066486..c5a630d73bd 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -142,6 +142,7 @@ SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_POLL_UPDATE = "sonos_poll_update" SONOS_ALARMS_UPDATED = "sonos_alarms_updated" SONOS_FAVORITES_UPDATED = "sonos_favorites_updated" +SONOS_SPEAKER_ADDED = "sonos_speaker_added" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_REBOOTED = "sonos_rebooted" SONOS_SEEN = "sonos_seen" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0948e971baf..5cb6e225510 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -125,6 +125,8 @@ ATTR_NIGHT_SOUND = "night_sound" ATTR_SPEECH_ENHANCE = "speech_enhance" ATTR_QUEUE_POSITION = "queue_position" ATTR_STATUS_LIGHT = "status_light" +ATTR_EQ_BASS = "bass_level" +ATTR_EQ_TREBLE = "treble_level" async def async_setup_entry( @@ -236,6 +238,12 @@ async def async_setup_entry( vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, vol.Optional(ATTR_STATUS_LIGHT): cv.boolean, + vol.Optional(ATTR_EQ_BASS): vol.All( + vol.Coerce(int), vol.Range(min=-10, max=10) + ), + vol.Optional(ATTR_EQ_TREBLE): vol.All( + vol.Coerce(int), vol.Range(min=-10, max=10) + ), }, "set_option", ) @@ -615,6 +623,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): night_sound: bool | None = None, speech_enhance: bool | None = None, status_light: bool | None = None, + bass_level: int | None = None, + treble_level: int | None = None, ) -> None: """Modify playback options.""" if buttons_enabled is not None: @@ -632,6 +642,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if status_light is not None: self.soco.status_light = status_light + if bass_level is not None: + self.soco.bass = bass_level + + if treble_level is not None: + self.soco.treble = treble_level + @soco_error() def play_queue(self, queue_position: int = 0) -> None: """Start playing the queue.""" @@ -649,6 +665,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ATTR_SONOS_GROUP: self.speaker.sonos_group_entities } + if self.speaker.bass_level is not None: + attributes[ATTR_EQ_BASS] = self.speaker.bass_level + + if self.speaker.treble_level is not None: + attributes[ATTR_EQ_TREBLE] = self.speaker.treble_level + if self.speaker.night_mode is not None: attributes[ATTR_NIGHT_SOUND] = self.speaker.night_mode diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index 9e5277819a7..1a13e6f55f4 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -45,7 +45,7 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): return DEVICE_CLASS_BATTERY @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Get the unit of measurement.""" return PERCENTAGE @@ -54,7 +54,7 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): await self.speaker.async_poll_battery() @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" return self.speaker.battery_info.get("Level") diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 76bc656f990..9858eb7f8ed 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -121,6 +121,22 @@ set_option: description: Enable Status (LED) Light selector: boolean: + bass_level: + name: Bass Level + description: Bass level for EQ. + selector: + number: + min: -10 + max: 10 + mode: box + treble_level: + name: Treble Level + description: Treble level for EQ. + selector: + number: + min: -10 + max: 10 + mode: box play_queue: name: Play queue diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 919e03cf39b..30d107bdd8d 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -50,6 +50,7 @@ from .const import ( SONOS_POLL_UPDATE, SONOS_REBOOTED, SONOS_SEEN, + SONOS_SPEAKER_ADDED, SONOS_STATE_PLAYING, SONOS_STATE_TRANSITIONING, SONOS_STATE_UPDATED, @@ -189,6 +190,8 @@ class SonosSpeaker: self.muted: bool | None = None self.night_mode: bool | None = None self.dialog_mode: bool | None = None + self.bass_level: int | None = None + self.treble_level: int | None = None # Grouping self.coordinator: SonosSpeaker | None = None @@ -196,6 +199,7 @@ class SonosSpeaker: self.sonos_group_entities: list[str] = [] self.soco_snapshot: Snapshot | None = None self.snapshot_group: list[SonosSpeaker] | None = None + self._group_members_missing: set[str] = set() def setup(self) -> None: """Run initial setup of the speaker.""" @@ -212,6 +216,11 @@ class SonosSpeaker: self._reboot_dispatcher = dispatcher_connect( self.hass, f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted ) + self._group_dispatcher = dispatcher_connect( + self.hass, + SONOS_SPEAKER_ADDED, + self.update_group_for_uid, + ) if battery_info := fetch_battery_info_or_none(self.soco): self.battery_info = battery_info @@ -240,6 +249,7 @@ class SonosSpeaker: } dispatcher_send(self.hass, SONOS_CREATE_MEDIA_PLAYER, self) + dispatcher_send(self.hass, SONOS_SPEAKER_ADDED, self.soco.uid) # # Entity management @@ -313,6 +323,18 @@ class SonosSpeaker: async def async_subscribe(self) -> bool: """Initiate event subscriptions.""" _LOGGER.debug("Creating subscriptions for %s", self.zone_name) + + # Create a polling task in case subscriptions fail or callback events do not arrive + if not self._poll_timer: + self._poll_timer = self.hass.helpers.event.async_track_time_interval( + partial( + async_dispatcher_send, + self.hass, + f"{SONOS_POLL_UPDATE}-{self.soco.uid}", + ), + SCAN_INTERVAL, + ) + try: await self.hass.async_add_executor_job(self.set_basic_info) @@ -327,10 +349,10 @@ class SonosSpeaker: for service in SUBSCRIPTION_SERVICES ] await asyncio.gather(*subscriptions) - return True except SoCoException as ex: _LOGGER.warning("Could not connect %s: %s", self.zone_name, ex) return False + return True async def _subscribe( self, target: SubscriptionBase, sub_callback: Callable @@ -346,10 +368,13 @@ class SonosSpeaker: async def async_unsubscribe(self) -> None: """Cancel all subscriptions.""" _LOGGER.debug("Unsubscribing from events for %s", self.zone_name) - await asyncio.gather( + results = await asyncio.gather( *(subscription.unsubscribe() for subscription in self._subscriptions), return_exceptions=True, ) + for result in results: + if isinstance(result, Exception): + _LOGGER.debug("Unsubscribe failed for %s: %s", self.zone_name, result) self._subscriptions = [] @callback @@ -449,6 +474,12 @@ class SonosSpeaker: if "dialog_level" in variables: self.dialog_mode = variables["dialog_level"] == "1" + if "bass_level" in variables: + self.bass_level = variables["bass_level"] + + if "treble_level" in variables: + self.treble_level = variables["treble_level"] + self.async_write_entity_states() # @@ -478,15 +509,6 @@ class SonosSpeaker: self.soco.ip_address, ) - self._poll_timer = self.hass.helpers.event.async_track_time_interval( - partial( - async_dispatcher_send, - self.hass, - f"{SONOS_POLL_UPDATE}-{self.soco.uid}", - ), - SCAN_INTERVAL, - ) - if self._is_ready and not self.subscriptions_failed: done = await self.async_subscribe() if not done: @@ -496,21 +518,26 @@ class SonosSpeaker: self.async_write_entity_states() - async def async_unseen(self, now: datetime.datetime | None = None) -> None: + async def async_unseen( + 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 - 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 - ) - return + if callback_timestamp: + # 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 + ) + return _LOGGER.debug( "No activity and could not locate %s on the network. Marking unavailable", @@ -543,15 +570,6 @@ class SonosSpeaker: self._seen_timer = self.hass.helpers.event.async_call_later( SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen ) - if not self._poll_timer: - self._poll_timer = self.hass.helpers.event.async_track_time_interval( - partial( - async_dispatcher_send, - self.hass, - f"{SONOS_POLL_UPDATE}-{self.soco.uid}", - ), - SCAN_INTERVAL, - ) self.async_write_entity_states() # @@ -629,12 +647,22 @@ class SonosSpeaker: """Update group topology when polling.""" self.hass.add_job(self.create_update_groups_coro()) + def update_group_for_uid(self, uid: str) -> None: + """Update group topology if uid is missing.""" + if uid not in self._group_members_missing: + return + missing_zone = self.hass.data[DATA_SONOS].discovered[uid].zone_name + _LOGGER.debug( + "%s was missing, adding to %s group", missing_zone, self.zone_name + ) + self.update_groups() + @callback def async_update_groups(self, event: SonosEvent) -> None: """Handle callback for topology change event.""" if not hasattr(event, "zone_player_uui_ds_in_group"): - return None - self.hass.async_add_job(self.create_update_groups_coro(event)) + return + self.hass.async_create_task(self.create_update_groups_coro(event)) def create_update_groups_coro(self, event: SonosEvent | None = None) -> Coroutine: """Handle callback for topology change event.""" @@ -650,7 +678,7 @@ class SonosSpeaker: slave_uids = [ p.uid for p in self.soco.group.members - if p.uid != coordinator_uid + if p.uid != coordinator_uid and p.is_visible ] return [coordinator_uid] + slave_uids @@ -682,11 +710,19 @@ class SonosSpeaker: for uid in group: speaker = self.hass.data[DATA_SONOS].discovered.get(uid) if speaker: + self._group_members_missing.discard(uid) sonos_group.append(speaker) entity_id = entity_registry.async_get_entity_id( MP_DOMAIN, DOMAIN, uid ) sonos_group_entities.append(entity_id) + else: + self._group_members_missing.add(uid) + _LOGGER.debug( + "%s group member unavailable (%s), will try again", + self.zone_name, + uid, + ) if self.sonos_group_entities == sonos_group_entities: # Useful in polling mode for speakers with stereo pairs or surrounds @@ -948,6 +984,8 @@ class SonosSpeaker: self.muted = self.soco.mute self.night_mode = self.soco.night_mode self.dialog_mode = self.soco.dialog_mode + self.bass_level = self.soco.bass + self.treble_level = self.soco.treble def update_media(self, event: SonosEvent | None = None) -> None: """Update information about currently playing media.""" diff --git a/homeassistant/components/sonos/translations/no.json b/homeassistant/components/sonos/translations/no.json index 2da0b5a1b0b..2e9b464f5f2 100644 --- a/homeassistant/components/sonos/translations/no.json +++ b/homeassistant/components/sonos/translations/no.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "not_sonos_device": "Oppdaget enhet er ikke en Sonos -enhet", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "step": { diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index 04f3ea0cc55..c9962362406 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,17 +1,38 @@ """Consts used by Speedtest.net.""" +from __future__ import annotations + from typing import Final +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND, TIME_MILLISECONDS DOMAIN: Final = "speedtestdotnet" SPEED_TEST_SERVICE: Final = "speedtest" -SENSOR_TYPES: Final = { - "ping": ["Ping", TIME_MILLISECONDS], - "download": ["Download", DATA_RATE_MEGABITS_PER_SECOND], - "upload": ["Upload", DATA_RATE_MEGABITS_PER_SECOND], -} +SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key="ping", + name="Ping", + native_unit_of_measurement=TIME_MILLISECONDS, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="download", + name="Download", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="upload", + name="Upload", + native_unit_of_measurement=DATA_RATE_MEGABITS_PER_SECOND, + state_class=STATE_CLASS_MEASUREMENT, + ), +) CONF_SERVER_NAME: Final = "server_name" CONF_SERVER_ID: Final = "server_id" diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 8dcc5bc3459..2dc12c956de 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.speedtestdotnet import SpeedTestDataCoordinator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION @@ -34,8 +34,8 @@ async def async_setup_entry( """Set up the Speedtestdotnet sensors.""" speedtest_coordinator = hass.data[DOMAIN] async_add_entities( - SpeedtestSensor(speedtest_coordinator, sensor_type) - for sensor_type in SENSOR_TYPES + SpeedtestSensor(speedtest_coordinator, description) + for description in SENSOR_TYPES ) @@ -46,14 +46,17 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): _attr_icon = ICON - def __init__(self, coordinator: SpeedTestDataCoordinator, sensor_type: str) -> None: + def __init__( + self, + coordinator: SpeedTestDataCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self.type = sensor_type + self.entity_description = description - self._attr_name = f"{DEFAULT_NAME} {SENSOR_TYPES[sensor_type][0]}" - self._attr_unit_of_measurement = SENSOR_TYPES[self.type][1] - self._attr_unique_id = sensor_type + self._attr_name = f"{DEFAULT_NAME} {description.name}" + self._attr_unique_id = description.key self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} @property @@ -68,11 +71,11 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): } ) - if self.type == "download": + if self.entity_description.key == "download": self._attrs[ATTR_BYTES_RECEIVED] = self.coordinator.data[ "bytes_received" ] - elif self.type == "upload": + elif self.entity_description.key == "upload": self._attrs[ATTR_BYTES_SENT] = self.coordinator.data["bytes_sent"] return self._attrs @@ -82,7 +85,7 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): await super().async_added_to_hass() state = await self.async_get_last_state() if state: - self._attr_state = state.state + self._attr_native_value = state.state @callback def update() -> None: @@ -96,9 +99,13 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): def _update_state(self): """Update sensors state.""" if self.coordinator.data: - if self.type == "ping": - self._attr_state = self.coordinator.data["ping"] - elif self.type == "download": - self._attr_state = round(self.coordinator.data["download"] / 10 ** 6, 2) - elif self.type == "upload": - self._attr_state = round(self.coordinator.data["upload"] / 10 ** 6, 2) + 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 + ) diff --git a/homeassistant/components/speedtestdotnet/translations/hu.json b/homeassistant/components/speedtestdotnet/translations/hu.json index ec08c711e1d..cd08c3bd2d6 100644 --- a/homeassistant/components/speedtestdotnet/translations/hu.json +++ b/homeassistant/components/speedtestdotnet/translations/hu.json @@ -14,6 +14,8 @@ "step": { "init": { "data": { + "manual": "Automatikus friss\u00edt\u00e9s letilt\u00e1sa", + "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g (perc)", "server_name": "V\u00e1laszd ki a teszt szervert" } } diff --git a/homeassistant/components/spider/sensor.py b/homeassistant/components/spider/sensor.py index 998a9ff8eee..8b38fdbe6f6 100644 --- a/homeassistant/components/spider/sensor.py +++ b/homeassistant/components/spider/sensor.py @@ -1,7 +1,9 @@ """Support for Spider Powerplugs (energy & power).""" -from datetime import datetime - -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -9,7 +11,6 @@ from homeassistant.const import ( POWER_WATT, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -29,9 +30,9 @@ async def async_setup_entry(hass, config, async_add_entities): class SpiderPowerPlugEnergy(SensorEntity): """Representation of a Spider Power Plug (energy).""" - _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_device_class = DEVICE_CLASS_ENERGY - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__(self, api, power_plug) -> None: """Initialize the Spider Power Plug.""" @@ -59,17 +60,10 @@ class SpiderPowerPlugEnergy(SensorEntity): return f"{self.power_plug.name} Total Energy Today" @property - def state(self) -> float: + def native_value(self) -> float: """Return todays energy usage in Kwh.""" return round(self.power_plug.today_energy_consumption / 1000, 2) - @property - def last_reset(self) -> datetime: - """Return the time when last reset; Every midnight.""" - return dt_util.as_utc( - dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - def update(self) -> None: """Get the latest data.""" self.power_plug = self.api.get_power_plug(self.power_plug.id) @@ -80,7 +74,7 @@ class SpiderPowerPlugPower(SensorEntity): _attr_device_class = DEVICE_CLASS_POWER _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = POWER_WATT + _attr_native_unit_of_measurement = POWER_WATT def __init__(self, api, power_plug) -> None: """Initialize the Spider Power Plug.""" @@ -108,7 +102,7 @@ class SpiderPowerPlugPower(SensorEntity): return f"{self.power_plug.name} Power Consumption" @property - def state(self) -> float: + def native_value(self) -> float: """Return the current power usage in W.""" return round(self.power_plug.current_energy_consumption) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index c88aa453d2c..fedec630c35 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -491,7 +491,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ) raise NotImplementedError - if media_content_type in [None, "library"]: + if media_content_type in (None, "library"): return await self.hass.async_add_executor_job(library_payload) payload = { diff --git a/homeassistant/components/spotify/translations/en_GB.json b/homeassistant/components/spotify/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/spotify/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/translations/zh-Hans.json b/homeassistant/components/spotify/translations/zh-Hans.json index 19a6909de48..fdda1685cf1 100644 --- a/homeassistant/components/spotify/translations/zh-Hans.json +++ b/homeassistant/components/spotify/translations/zh-Hans.json @@ -1,4 +1,23 @@ { + "config": { + "abort": { + "authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002", + "missing_configuration": "Spotify \u96c6\u6210\u672a\u914d\u7f6e \u3002\u8bf7\u9075\u5faa\u6587\u6863\u914d\u7f6e\u3002", + "no_url_available": "\u65e0 URL \u53ef\u7528\uff0c\u66f4\u591a\u4fe1\u606f\u8bf7[check the help section]({docs_url})", + "reauth_account_mismatch": "\u5df2\u9a8c\u8bc1\u7684 Spotify \u5e10\u6237\u4e0e\u9700\u8981\u91cd\u65b0\u9a8c\u8bc1\u7684\u5e10\u6237\u4e0d\u5339\u914d\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u901a\u8fc7 Spotify \u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1\u3002" + }, + "step": { + "pick_implementation": { + "title": "\u9009\u62e9\u9a8c\u8bc1\u65b9\u5f0f" + }, + "reauth_confirm": { + "description": "Spotify \u96c6\u6210\u9700\u8981\u91cd\u65b0\u9a8c\u8bc1\u5e10\u6237\uff1a {account}" + } + } + }, "system_health": { "info": { "api_endpoint_reachable": "\u53ef\u8bbf\u95ee Spotify API" diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index a2a197a0eb0..4796dac11a9 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -2,7 +2,7 @@ "domain": "sql", "name": "SQL", "documentation": "https://www.home-assistant.io/integrations/sql", - "requirements": ["sqlalchemy==1.4.17"], + "requirements": ["sqlalchemy==1.4.23"], "codeowners": ["@dgomes"], "iot_class": "local_polling" } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 4c1c29b82a6..1b0ae5a9076 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -123,12 +123,12 @@ class SQLSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the query's current state.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 4c0ec186707..294a1105a71 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -137,7 +137,6 @@ async def build_item_response(entity, player, payload): async def library_payload(player): """Create response payload to describe contents of library.""" - library_info = { "title": "Music Library", "media_class": MEDIA_CLASS_DIRECTORY, diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 1f1c23942db..4b05588e281 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -5,8 +5,9 @@ import logging from pysqueezebox import Server, async_discover import voluptuous as vol -from homeassistant import config_entries -from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.dhcp import MAC_ADDRESS +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,6 +16,8 @@ from homeassistant.const import ( HTTP_UNAUTHORIZED, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_registry import async_get from .const import DEFAULT_PORT, DOMAIN @@ -166,28 +169,18 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason=error) return self.async_create_entry(title=config[CONF_HOST], data=config) - async def async_step_discovery(self, discovery_info): - """Handle discovery.""" - _LOGGER.debug("Reached discovery flow with info: %s", discovery_info) + async def async_step_integration_discovery(self, discovery_info): + """Handle discovery of a server.""" + _LOGGER.debug("Reached server discovery flow with info: %s", discovery_info) if "uuid" in discovery_info: await self.async_set_unique_id(discovery_info.pop("uuid")) self._abort_if_unique_id_configured() else: # attempt to connect to server and determine uuid. will fail if # password required - - if CONF_HOST not in discovery_info and IP_ADDRESS in discovery_info: - discovery_info[CONF_HOST] = discovery_info[IP_ADDRESS] - - if CONF_PORT not in discovery_info: - discovery_info[CONF_PORT] = DEFAULT_PORT - error = await self._validate_input(discovery_info) if error: - if MAC_ADDRESS in discovery_info: - await self.async_set_unique_id(discovery_info[MAC_ADDRESS]) - else: - await self._async_handle_discovery_without_unique_id() + await self._async_handle_discovery_without_unique_id() # update schema with suggested values from discovery self.data_schema = _base_schema(discovery_info) @@ -195,3 +188,23 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context.update({"title_placeholders": {"host": discovery_info[CONF_HOST]}}) return await self.async_step_edit() + + async def async_step_dhcp(self, discovery_info): + """Handle dhcp discovery of a Squeezebox player.""" + _LOGGER.debug( + "Reached dhcp discovery of a player with info: %s", discovery_info + ) + await self.async_set_unique_id(format_mac(discovery_info[MAC_ADDRESS])) + self._abort_if_unique_id_configured() + + _LOGGER.debug("Configuring dhcp player with unique id: %s", self.unique_id) + + registry = async_get(self.hass) + + # if we have detected this player, do nothing. if not, there must be a server out there for us to configure, so start the normal user flow (which tries to autodetect server) + if registry.async_get_entity_id(MP_DOMAIN, DOMAIN, self.unique_id) is not None: + # this player is already known, so do nothing other than mark as configured + raise data_entry_flow.AbortFlow("already_configured") + + # if the player is unknown, then we likely need to configure its server + return await self.async_step_user() diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index baf8a011c65..1ba406097d7 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -27,7 +27,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) -from homeassistant.config_entries import SOURCE_DISCOVERY +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY from homeassistant.const import ( ATTR_COMMAND, CONF_HOST, @@ -43,6 +43,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -127,7 +128,7 @@ async def start_server_discovery(hass): asyncio.create_task( hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_DISCOVERY}, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, data={ CONF_HOST: server.host, CONF_PORT: int(server.port), @@ -146,7 +147,6 @@ async def start_server_discovery(hass): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up squeezebox platform from platform entry in configuration.yaml (deprecated).""" - if config: await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config @@ -283,7 +283,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): @property def unique_id(self): """Return a unique ID.""" - return self._player.player_id + return format_mac(self._player.player_id) @property def available(self): @@ -573,7 +573,6 @@ class SqueezeBoxEntity(MediaPlayerEntity): async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" - _LOGGER.debug( "Reached async_browse_media with content_type %s and content_id %s", media_content_type, diff --git a/homeassistant/components/squeezebox/translations/hu.json b/homeassistant/components/squeezebox/translations/hu.json index e9d7413ebfa..a047dbca45f 100644 --- a/homeassistant/components/squeezebox/translations/hu.json +++ b/homeassistant/components/squeezebox/translations/hu.json @@ -18,7 +18,8 @@ "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Kapcsolati inform\u00e1ci\u00f3k szerkeszt\u00e9se" }, "user": { "data": { diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 6973c58600e..97b65840e83 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -93,14 +93,14 @@ class SrpEntity(SensorEntity): return self.type @property - def state(self): + def native_value(self): """Return the state of the device.""" if self._state: return f"{self._state:.2f}" return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/srp_energy/translations/hu.json b/homeassistant/components/srp_energy/translations/hu.json index 9ade185d831..4d617e09cfc 100644 --- a/homeassistant/components/srp_energy/translations/hu.json +++ b/homeassistant/components/srp_energy/translations/hu.json @@ -13,6 +13,7 @@ "user": { "data": { "id": "A fi\u00f3k azonos\u00edt\u00f3ja", + "is_tou": "A haszn\u00e1lati id\u0151 terv", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } diff --git a/homeassistant/components/srp_energy/translations/zh-Hans.json b/homeassistant/components/srp_energy/translations/zh-Hans.json new file mode 100644 index 00000000000..36016f3e217 --- /dev/null +++ b/homeassistant/components/srp_energy/translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + } + } + }, + "title": "SRP Energy" +} \ No newline at end of file diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 31ebb0d1a92..6e9441534ab 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -9,6 +9,7 @@ import logging from typing import Any, Callable from async_upnp_client.search import SSDPListener +from async_upnp_client.ssdp import SSDP_PORT from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries @@ -115,14 +116,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@core_callback -def _async_use_default_interface(adapters: list[network.Adapter]) -> bool: - for adapter in adapters: - if adapter["enabled"] and not adapter["default"]: - return False - return True - - @core_callback def _async_process_callbacks( callbacks: list[Callable[[dict], None]], discovery_info: dict[str, str] @@ -203,30 +196,29 @@ class Scanner: """Build the list of ssdp sources.""" adapters = await network.async_get_adapters(self.hass) sources: set[IPv4Address | IPv6Address] = set() - if _async_use_default_interface(adapters): + if network.async_only_default_interface_enabled(adapters): sources.add(IPv4Address("0.0.0.0")) return sources - for adapter in adapters: - if not adapter["enabled"]: - continue - if adapter["ipv4"]: - ipv4 = adapter["ipv4"][0] - sources.add(IPv4Address(ipv4["address"])) - if adapter["ipv6"]: - ipv6 = adapter["ipv6"][0] - # With python 3.9 add scope_ids can be - # added by enumerating adapter["ipv6"]s - # IPv6Address(f"::%{ipv6['scope_id']}") - sources.add(IPv6Address(ipv6["address"])) + return { + source_ip + for source_ip in await network.async_get_enabled_source_ips(self.hass) + if not source_ip.is_loopback + and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) + } - return sources - - @core_callback - def async_scan(self, *_: Any) -> None: - """Scan for new entries.""" + async def async_scan(self, *_: Any) -> None: + """Scan for new entries using ssdp default and broadcast target.""" 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)) async def async_start(self) -> None: """Start the scanner.""" @@ -235,21 +227,9 @@ class Scanner: for source_ip in await self._async_build_source_set(): self._ssdp_listeners.append( SSDPListener( - async_callback=self._async_process_entry, source_ip=source_ip - ) - ) - try: - IPv4Address(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 - self._ssdp_listeners.append( - SSDPListener( + async_connect_callback=self.async_scan, async_callback=self._async_process_entry, source_ip=source_ip, - target_ip=IPV4_BROADCAST, ) ) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -306,6 +286,11 @@ class Scanner: if header_st is not None: self.seen.add((header_st, header_location)) + 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.remove((header_st, header_location)) + async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" _LOGGER.debug("_async_process_entry: %s", headers) @@ -313,7 +298,12 @@ class Scanner: h_location = headers.get("location") if h_st and (udn := _udn_from_usn(headers.get("usn"))): - self.cache[(udn, h_st)] = headers + 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: diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 432686d9027..746e90c7388 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ssdp", "requirements": [ "defusedxml==0.7.1", - "async-upnp-client==0.19.1" + "async-upnp-client==0.20.0" ], "dependencies": ["network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index 3468d141cf6..e2e3d7ea4fa 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -1,57 +1,92 @@ """Reads vehicle status from StarLine API.""" +from __future__ import annotations + +from dataclasses import dataclass + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_DOOR, DEVICE_CLASS_LOCK, DEVICE_CLASS_POWER, DEVICE_CLASS_PROBLEM, BinarySensorEntity, + BinarySensorEntityDescription, ) from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity -SENSOR_TYPES = { - "hbrake": ["Hand Brake", DEVICE_CLASS_POWER], - "hood": ["Hood", DEVICE_CLASS_DOOR], - "trunk": ["Trunk", DEVICE_CLASS_DOOR], - "alarm": ["Alarm", DEVICE_CLASS_PROBLEM], - "door": ["Doors", DEVICE_CLASS_LOCK], -} + +@dataclass +class StarlineRequiredKeysMixin: + """Mixin for required keys.""" + + name_: str + + +@dataclass +class StarlineBinarySensorEntityDescription( + BinarySensorEntityDescription, StarlineRequiredKeysMixin +): + """Describes Starline binary_sensor entity.""" + + +BINARY_SENSOR_TYPES: tuple[StarlineBinarySensorEntityDescription, ...] = ( + StarlineBinarySensorEntityDescription( + key="hbrake", + name_="Hand Brake", + device_class=DEVICE_CLASS_POWER, + ), + StarlineBinarySensorEntityDescription( + key="hood", + name_="Hood", + device_class=DEVICE_CLASS_DOOR, + ), + StarlineBinarySensorEntityDescription( + key="trunk", + name_="Trunk", + device_class=DEVICE_CLASS_DOOR, + ), + StarlineBinarySensorEntityDescription( + key="alarm", + name_="Alarm", + device_class=DEVICE_CLASS_PROBLEM, + ), + StarlineBinarySensorEntityDescription( + key="door", + name_="Doors", + device_class=DEVICE_CLASS_LOCK, + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up the StarLine sensors.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] - entities = [] - for device in account.api.devices.values(): - for key, value in SENSOR_TYPES.items(): - if key in device.car_state: - sensor = StarlineSensor(account, device, key, *value) - if sensor.is_on is not None: - entities.append(sensor) + entities = [ + sensor + for device in account.api.devices.values() + for description in BINARY_SENSOR_TYPES + if description.key in device.car_state + if (sensor := StarlineSensor(account, device, description)).is_on is not None + ] async_add_entities(entities) class StarlineSensor(StarlineEntity, BinarySensorEntity): """Representation of a StarLine binary sensor.""" + entity_description: StarlineBinarySensorEntityDescription + def __init__( self, account: StarlineAccount, device: StarlineDevice, - key: str, - name: str, - device_class: str, + description: StarlineBinarySensorEntityDescription, ) -> None: """Initialize sensor.""" - super().__init__(account, device, key, name) - self._device_class = device_class - - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._device_class + super().__init__(account, device, description.key, description.name_) + self.entity_description = description @property def is_on(self): diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index e7996befad3..26834cc384c 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -1,5 +1,13 @@ """Reads vehicle status from StarLine API.""" -from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE, SensorEntity +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + DEVICE_CLASS_TEMPERATURE, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( ELECTRIC_POTENTIAL_VOLT, LENGTH_KILOMETERS, @@ -13,48 +21,94 @@ from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity -SENSOR_TYPES = { - "battery": ["Battery", None, ELECTRIC_POTENTIAL_VOLT, None], - "balance": ["Balance", None, None, "mdi:cash-multiple"], - "ctemp": ["Interior Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None], - "etemp": ["Engine Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None], - "gsm_lvl": ["GSM Signal", None, PERCENTAGE, None], - "fuel": ["Fuel Volume", None, None, "mdi:fuel"], - "errors": ["OBD Errors", None, None, "mdi:alert-octagon"], - "mileage": ["Mileage", None, LENGTH_KILOMETERS, "mdi:counter"], -} + +@dataclass +class StarlineRequiredKeysMixin: + """Mixin for required keys.""" + + name_: str + + +@dataclass +class StarlineSensorEntityDescription( + SensorEntityDescription, StarlineRequiredKeysMixin +): + """Describes Starline binary_sensor entity.""" + + +SENSOR_TYPES: tuple[StarlineSensorEntityDescription, ...] = ( + StarlineSensorEntityDescription( + key="battery", + name_="Battery", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + ), + StarlineSensorEntityDescription( + key="balance", + name_="Balance", + icon="mdi:cash-multiple", + ), + StarlineSensorEntityDescription( + key="ctemp", + name_="Interior Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + StarlineSensorEntityDescription( + key="etemp", + name_="Engine Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), + StarlineSensorEntityDescription( + key="gsm_lvl", + name_="GSM Signal", + native_unit_of_measurement=PERCENTAGE, + ), + StarlineSensorEntityDescription( + key="fuel", + name_="Fuel Volume", + icon="mdi:fuel", + ), + StarlineSensorEntityDescription( + key="errors", + name_="OBD Errors", + icon="mdi:alert-octagon", + ), + StarlineSensorEntityDescription( + key="mileage", + name_="Mileage", + native_unit_of_measurement=LENGTH_KILOMETERS, + icon="mdi:counter", + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up the StarLine sensors.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] - entities = [] - for device in account.api.devices.values(): - for key, value in SENSOR_TYPES.items(): - sensor = StarlineSensor(account, device, key, *value) - if sensor.state is not None: - entities.append(sensor) + entities = [ + sensor + for device in account.api.devices.values() + for description in SENSOR_TYPES + if (sensor := StarlineSensor(account, device, description)).state is not None + ] async_add_entities(entities) class StarlineSensor(StarlineEntity, SensorEntity): """Representation of a StarLine sensor.""" + entity_description: StarlineSensorEntityDescription + def __init__( self, account: StarlineAccount, device: StarlineDevice, - key: str, - name: str, - device_class: str, - unit: str, - icon: str, + description: StarlineSensorEntityDescription, ) -> None: """Initialize StarLine sensor.""" - super().__init__(account, device, key, name) - self._device_class = device_class - self._unit = unit - self._icon = icon + super().__init__(account, device, description.key, description.name_) + self.entity_description = description @property def icon(self): @@ -66,10 +120,10 @@ class StarlineSensor(StarlineEntity, SensorEntity): ) if self._key == "gsm_lvl": return icon_for_signal_level(signal_level=self._device.gsm_level_percent) - return self._icon + return self.entity_description.icon @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._key == "battery": return self._device.battery_level @@ -90,7 +144,7 @@ class StarlineSensor(StarlineEntity, SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Get the unit of measurement.""" if self._key == "balance": return self._device.balance.get("currency") or "₽" @@ -100,12 +154,7 @@ class StarlineSensor(StarlineEntity, SensorEntity): return PERCENTAGE if type_value == "litres": return VOLUME_LITERS - return self._unit - - @property - def device_class(self): - """Return the class of the sensor.""" - return self._device_class + return self.entity_description.native_unit_of_measurement @property def extra_state_attributes(self): diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index c8afc41cb2d..684e7ecc662 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -1,51 +1,86 @@ """Support for StarLine switch.""" -from homeassistant.components.switch import SwitchEntity +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity -SWITCH_TYPES = { - "ign": ["Engine", "mdi:engine-outline", "mdi:engine-off-outline"], - "webasto": ["Webasto", "mdi:radiator", "mdi:radiator-off"], - "out": [ - "Additional Channel", - "mdi:access-point-network", - "mdi:access-point-network-off", - ], - "poke": ["Horn", "mdi:bullhorn-outline", "mdi:bullhorn-outline"], -} + +@dataclass +class StarlineRequiredKeysMixin: + """Mixin for required keys.""" + + name_: str + icon_on: str + icon_off: str + + +@dataclass +class StarlineSwitchEntityDescription( + SwitchEntityDescription, StarlineRequiredKeysMixin +): + """Describes Starline switch entity.""" + + +SWITCH_TYPES: tuple[StarlineSwitchEntityDescription, ...] = ( + StarlineSwitchEntityDescription( + key="ign", + name_="Engine", + icon_on="mdi:engine-outline", + icon_off="mdi:engine-off-outline", + ), + StarlineSwitchEntityDescription( + key="webasto", + name_="Webasto", + icon_on="mdi:radiator", + icon_off="mdi:radiator-off", + ), + StarlineSwitchEntityDescription( + key="out", + name_="Additional Channel", + icon_on="mdi:access-point-network", + icon_off="mdi:access-point-network-off", + ), + StarlineSwitchEntityDescription( + key="poke", + name_="Horn", + icon_on="mdi:bullhorn-outline", + icon_off="mdi:bullhorn-outline", + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up the StarLine switch.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] - entities = [] - for device in account.api.devices.values(): - if device.support_state: - for key, value in SWITCH_TYPES.items(): - switch = StarlineSwitch(account, device, key, *value) - if switch.is_on is not None: - entities.append(switch) + entities = [ + switch + for device in account.api.devices.values() + if device.support_state + for description in SWITCH_TYPES + if (switch := StarlineSwitch(account, device, description)).is_on is not None + ] async_add_entities(entities) class StarlineSwitch(StarlineEntity, SwitchEntity): """Representation of a StarLine switch.""" + entity_description: StarlineSwitchEntityDescription + def __init__( self, account: StarlineAccount, device: StarlineDevice, - key: str, - name: str, - icon_on: str, - icon_off: str, + description: StarlineSwitchEntityDescription, ) -> None: """Initialize the switch.""" - super().__init__(account, device, key, name) - self._icon_on = icon_on - self._icon_off = icon_off + super().__init__(account, device, description.key, description.name_) + self.entity_description = description @property def available(self): @@ -62,7 +97,11 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): @property def icon(self): """Icon to use in the frontend, if any.""" - return self._icon_on if self.is_on else self._icon_off + return ( + self.entity_description.icon_on + if self.is_on + else self.entity_description.icon_off + ) @property def assumed_state(self): diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 77f5ab307cb..ae1ac2d4987 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -79,12 +79,12 @@ class StarlingBalanceSensor(SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._starling_account.currency diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 661e00ed494..8079ea42c4c 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -1,4 +1,6 @@ """Support for Start.ca Bandwidth Monitor.""" +from __future__ import annotations + from datetime import timedelta import logging from xml.parsers.expat import ExpatError @@ -7,7 +9,11 @@ import async_timeout import voluptuous as vol import xmltodict -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_API_KEY, CONF_MONITORED_VARIABLES, @@ -28,25 +34,87 @@ CONF_TOTAL_BANDWIDTH = "total_bandwidth" MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) REQUEST_TIMEOUT = 5 # seconds -SENSOR_TYPES = { - "usage": ["Usage Ratio", PERCENTAGE, "mdi:percent"], - "usage_gb": ["Usage", DATA_GIGABYTES, "mdi:download"], - "limit": ["Data limit", DATA_GIGABYTES, "mdi:download"], - "used_download": ["Used Download", DATA_GIGABYTES, "mdi:download"], - "used_upload": ["Used Upload", DATA_GIGABYTES, "mdi:upload"], - "used_total": ["Used Total", DATA_GIGABYTES, "mdi:download"], - "grace_download": ["Grace Download", DATA_GIGABYTES, "mdi:download"], - "grace_upload": ["Grace Upload", DATA_GIGABYTES, "mdi:upload"], - "grace_total": ["Grace Total", DATA_GIGABYTES, "mdi:download"], - "total_download": ["Total Download", DATA_GIGABYTES, "mdi:download"], - "total_upload": ["Total Upload", DATA_GIGABYTES, "mdi:download"], - "used_remaining": ["Remaining", DATA_GIGABYTES, "mdi:download"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="usage", + name="Usage Ratio", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + SensorEntityDescription( + key="usage_gb", + name="Usage", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="limit", + name="Data limit", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="used_download", + name="Used Download", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="used_upload", + name="Used Upload", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:upload", + ), + SensorEntityDescription( + key="used_total", + name="Used Total", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="grace_download", + name="Grace Download", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="grace_upload", + name="Grace Upload", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:upload", + ), + SensorEntityDescription( + key="grace_total", + name="Grace Total", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="total_download", + name="Total Download", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="total_upload", + name="Total Upload", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), + SensorEntityDescription( + key="used_remaining", + name="Remaining", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_VARIABLES): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_TOTAL_BANDWIDTH): cv.positive_int, @@ -58,8 +126,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the sensor platform.""" websession = async_get_clientsession(hass) - apikey = config.get(CONF_API_KEY) - bandwidthcap = config.get(CONF_TOTAL_BANDWIDTH) + apikey = config[CONF_API_KEY] + bandwidthcap = config[CONF_TOTAL_BANDWIDTH] ts_data = StartcaData(hass.loop, websession, apikey, bandwidthcap) ret = await ts_data.async_update() @@ -67,51 +135,32 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("Invalid Start.ca API key: %s", apikey) return - name = config.get(CONF_NAME) - sensors = [] - for variable in config[CONF_MONITORED_VARIABLES]: - sensors.append(StartcaSensor(ts_data, variable, name)) - async_add_entities(sensors, True) + name = config[CONF_NAME] + monitored_variables = config[CONF_MONITORED_VARIABLES] + entities = [ + StartcaSensor(ts_data, name, description) + for description in SENSOR_TYPES + if description.key in monitored_variables + ] + async_add_entities(entities, True) class StartcaSensor(SensorEntity): """Representation of Start.ca Bandwidth sensor.""" - def __init__(self, startcadata, sensor_type, name): + def __init__(self, startcadata, name, description: SensorEntityDescription): """Initialize the sensor.""" - self.client_name = name - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description self.startcadata = startcadata - self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + self._attr_name = f"{name} {description.name}" async def async_update(self): """Get the latest data from Start.ca and update the state.""" await self.startcadata.async_update() - if self.type in self.startcadata.data: - self._state = round(self.startcadata.data[self.type], 2) + sensor_type = self.entity_description.key + if sensor_type in self.startcadata.data: + self._attr_native_value = round(self.startcadata.data[sensor_type], 2) class StartcaData: diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index b1ea6cfb50f..ea90346fe7c 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -180,7 +180,7 @@ class StatisticsSensor(SensorEntity): def _add_state_to_queue(self, new_state): """Add the state to the queue.""" - if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): return try: @@ -203,12 +203,12 @@ class StatisticsSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.mean if not self.is_binary else self.count @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement if not self.is_binary else None diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 45ae1a6c70a..18f7c6cc447 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -99,7 +99,7 @@ class SteamSensor(SensorEntity): return f"sensor.steam_{self._account}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 69def43b2a2..039163c6cf5 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -225,7 +225,7 @@ class PeekIterator(Iterator): def replace_underlying_iterator(self, new_iterator: Iterator) -> None: """Replace the underlying iterator while preserving the buffer.""" self._iterator = new_iterator - if self._next is not self._pop_buffer: + if not self._buffer: self._next = self._iterator.__next__ def _pop_buffer(self) -> av.Packet: diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index ba722d0a4f2..3af87b8a3f8 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -87,12 +87,12 @@ class StreamLabsDailyUsage(SensorEntity): return WATER_ICON @property - def state(self): + def native_value(self): """Return the current daily usage.""" return self._streamlabs_usage_data.get_daily_usage() @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return gallons as the unit measurement for water.""" return VOLUME_GALLONS @@ -110,7 +110,7 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_MONTHLY_USAGE}" @property - def state(self): + def native_value(self): """Return the current monthly usage.""" return self._streamlabs_usage_data.get_monthly_usage() @@ -124,6 +124,6 @@ class StreamLabsYearlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_YEARLY_USAGE}" @property - def state(self): + def native_value(self): """Return the current yearly usage.""" return self._streamlabs_usage_data.get_yearly_usage() diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 694ddeff998..3b5efbcba9c 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -17,6 +17,7 @@ import attr from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_prepare_setup_platform from .const import ( @@ -34,7 +35,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up STT.""" providers = {} diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index ff1d8b715d7..7aeab66b929 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -203,7 +203,7 @@ class SubaruSensor(SubaruEntity, SensorEntity): return None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" self.current_value = self.get_current_value() @@ -238,7 +238,7 @@ class SubaruSensor(SubaruEntity, SensorEntity): return self.current_value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit_of_measurement of the device.""" if self.api_unit in TEMPERATURE_UNITS: return self.hass.config.units.temperature_unit diff --git a/homeassistant/components/subaru/translations/zh-Hans.json b/homeassistant/components/subaru/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/subaru/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 7170e0b8a67..c9c125e8e7e 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -62,12 +62,12 @@ class SuezSensor(SensorEntity): return COMPONENT_NAME @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return VOLUME_LITERS diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index f701df2d6c3..5db8680f1c9 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -50,7 +50,7 @@ class SupervisorProcessSensor(SensorEntity): return self._info.get("name") @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._info.get("statename") diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 5ebd6d6ca48..c8862a37b61 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -104,7 +104,7 @@ async def discover_devices(hass, hass_config): async with async_timeout.timeout(SCAN_INTERVAL.total_seconds()): channels = { channel["id"]: channel - for channel in await server.get_channels( + for channel in await server.get_channels( # pylint: disable=cell-var-from-loop include=["iodevice", "state", "connected"] ) } diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index e9a2c5b73a1..58890090d57 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -16,6 +16,7 @@ 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.typing import ConfigType from .const import ( ATTR_FLAP_ID, @@ -62,7 +63,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Sure Petcare integration.""" conf = config[DOMAIN] hass.data.setdefault(DOMAIN, {}) @@ -110,8 +111,6 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: vol.Lower, vol.In( [ - # https://github.com/PyCQA/pylint/issues/2062 - # pylint: disable=no-member LockState.UNLOCKED.name.lower(), LockState.LOCKED_IN.name.lower(), LockState.LOCKED_OUT.name.lower(), @@ -139,7 +138,7 @@ class SurePetcareAPI: """Initialize the Sure Petcare object.""" self.hass = hass self.surepy = surepy - self.states = {} + self.states: dict[int, Any] = {} async def async_update(self, _: Any = None) -> None: """Get the latest data from Sure Petcare.""" @@ -155,8 +154,6 @@ class SurePetcareAPI: async def set_lock_state(self, flap_id: int, state: str) -> None: """Update the lock state of a flap.""" - # https://github.com/PyCQA/pylint/issues/2062 - # pylint: disable=no-member if state == LockState.UNLOCKED.name.lower(): await self.surepy.sac.unlock(flap_id) elif state == LockState.LOCKED_IN.name.lower(): diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 5a9ae733db0..0f536d6135d 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -24,11 +24,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass, config, async_add_entities, discovery_info=None ) -> None: - """Set up Sure PetCare Flaps sensors based on a config entry.""" + """Set up Sure PetCare Flaps binary sensors based on a config entry.""" if discovery_info is None: return - entities: list[SurepyEntity] = [] + entities: list[SurepyEntity | Pet | Hub | DeviceConnectivity] = [] spc: SurePetcareAPI = hass.data[DOMAIN][SPC] @@ -42,8 +42,7 @@ async def async_setup_platform( EntityType.FELAQUA, ]: entities.append(DeviceConnectivity(surepy_entity.id, spc)) - - if surepy_entity.type == EntityType.PET: + elif surepy_entity.type == EntityType.PET: entities.append(Pet(surepy_entity.id, spc)) elif surepy_entity.type == EntityType.HUB: entities.append(Hub(surepy_entity.id, spc)) @@ -75,16 +74,10 @@ class SurePetcareBinarySensor(BinarySensorEntity): else: name = f"Unnamed {surepy_entity.type.name.capitalize()}" - self._name = f"{surepy_entity.type.name.capitalize()} {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}" - @property - def name(self) -> str: - """Return the name of the device if any.""" - return self._name - @abstractmethod @callback def _async_update(self) -> None: @@ -99,7 +92,7 @@ class SurePetcareBinarySensor(BinarySensorEntity): class Hub(SurePetcareBinarySensor): - """Sure Petcare Pet.""" + """Sure Petcare Hub.""" def __init__(self, _id: int, spc: SurePetcareAPI) -> None: """Initialize a Sure Petcare Hub.""" @@ -119,8 +112,8 @@ class Hub(SurePetcareBinarySensor): ), } else: - self._attr_extra_state_attributes = None - _LOGGER.debug("%s -> state: %s", self._name, state) + self._attr_extra_state_attributes = {} + _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() @@ -146,13 +139,13 @@ class Pet(SurePetcareBinarySensor): "where": state.where, } else: - self._attr_extra_state_attributes = None - _LOGGER.debug("%s -> state: %s", self._name, state) + self._attr_extra_state_attributes = {} + _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() class DeviceConnectivity(SurePetcareBinarySensor): - """Sure Petcare Pet.""" + """Sure Petcare Device.""" def __init__( self, @@ -161,15 +154,11 @@ class DeviceConnectivity(SurePetcareBinarySensor): ) -> 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" ) - @property - def name(self) -> str: - """Return the name of the device if any.""" - return f"{self._name}_connectivity" - @callback def _async_update(self) -> None: """Get the latest data and update the state.""" @@ -182,6 +171,6 @@ class DeviceConnectivity(SurePetcareBinarySensor): "hub_rssi": f'{state["signal"]["hub_rssi"]:.2f}', } else: - self._attr_extra_state_attributes = None - _LOGGER.debug("%s -> state: %s", self._name, state) + self._attr_extra_state_attributes = {} + _LOGGER.debug("%s -> state: %s", self.name, state) self.async_write_ha_state() diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index fbc8222f292..35d35e9be1f 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -59,8 +59,11 @@ class SureBattery(SensorEntity): surepy_entity: SurepyEntity = self._spc.states[_id] self._attr_device_class = DEVICE_CLASS_BATTERY - self._attr_name = f"{surepy_entity.type.name.capitalize()} {surepy_entity.name.capitalize()} Battery Level" - self._attr_unit_of_measurement = PERCENTAGE + 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" ) @@ -75,11 +78,11 @@ class SureBattery(SensorEntity): try: per_battery_voltage = state["battery"] / 4 voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW - self._attr_state = min( + self._attr_native_value = min( int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100 ) except (KeyError, TypeError): - self._attr_state = None + self._attr_native_value = None if state: voltage_per_battery = float(state["battery"]) / 4 @@ -88,7 +91,7 @@ class SureBattery(SensorEntity): f"{ATTR_VOLTAGE}_per_battery": f"{voltage_per_battery:.2f}", } else: - self._attr_extra_state_attributes = None + self._attr_extra_state_attributes = {} self.async_write_ha_state() _LOGGER.debug("%s -> state: %s", self.name, state) diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index 1d77410f031..3daa7161869 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -94,14 +94,14 @@ class SwissHydrologicalDataSensor(SensorEntity): return f"{self._station}_{self._condition}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" if self._state is not None: return self.hydro_data.data["parameters"][self._condition]["unit"] return None @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if isinstance(self._state, (int, float)): return round(self._state, 2) diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index a971524c22b..0f0ac28d530 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -84,7 +84,7 @@ class SwissPublicTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return ( self._opendata.connections[0]["departure"] diff --git a/homeassistant/components/switch/device_action.py b/homeassistant/components/switch/device_action.py index 0f3890d329f..6947656406b 100644 --- a/homeassistant/components/switch/device_action.py +++ b/homeassistant/components/switch/device_action.py @@ -10,6 +10,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN +# mypy: disallow-any-generics + ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) @@ -25,6 +27,8 @@ async def async_call_action_from_config( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions.""" return await toggle_entity.async_get_actions(hass, device_id, DOMAIN) diff --git a/homeassistant/components/switch/device_condition.py b/homeassistant/components/switch/device_condition.py index 15c2e54d193..b59e533375c 100644 --- a/homeassistant/components/switch/device_condition.py +++ b/homeassistant/components/switch/device_condition.py @@ -11,6 +11,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN +# mypy: disallow-any-generics + CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( {vol.Required(CONF_DOMAIN): DOMAIN} ) @@ -33,6 +35,8 @@ async def async_get_conditions( return await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) -async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List condition capabilities.""" return await toggle_entity.async_get_condition_capabilities(hass, config) diff --git a/homeassistant/components/switch/device_trigger.py b/homeassistant/components/switch/device_trigger.py index 15b700d9eb5..b796a31134f 100644 --- a/homeassistant/components/switch/device_trigger.py +++ b/homeassistant/components/switch/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for switches.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -28,11 +30,15 @@ async def async_attach_trigger( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers.""" return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index cff1a0d0edc..3fcf789da93 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -37,8 +37,8 @@ class SwitchBot(SwitchEntity, RestoreEntity): def __init__(self, mac, name, password) -> None: """Initialize the Switchbot.""" - self._state = None - self._last_run_success = None + 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) @@ -75,7 +75,7 @@ class SwitchBot(SwitchEntity, RestoreEntity): @property def is_on(self) -> bool: """Return true if device is on.""" - return self._state + return bool(self._state) @property def unique_id(self) -> str: diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 6c13067cd7f..6a23f1bb453 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_DEVICE_PASSWORD, @@ -49,7 +50,7 @@ CCONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the switcher component.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 705c6f0a2b6..e070bd52d0d 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -110,7 +110,7 @@ class SwitcherSensorEntity(CoordinatorEntity, SensorEntity): # Entity class attributes self._attr_name = f"{wrapper.name} {description.name}" self._attr_icon = description.icon - self._attr_unit_of_measurement = description.unit + self._attr_native_unit_of_measurement = description.unit self._attr_device_class = description.device_class self._attr_entity_registry_enabled_default = description.default_enabled @@ -122,6 +122,6 @@ class SwitcherSensorEntity(CoordinatorEntity, SensorEntity): } @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return value of sensor.""" return getattr(self.wrapper.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 c36fd0c208e..0eeeb881f45 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -137,13 +137,13 @@ class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): return bool(self.wrapper.data.device_state == DeviceState.ON) - async def async_turn_on(self, **kwargs: dict) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._async_call_api("control_device", Command.ON) self.control_result = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs: dict) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._async_call_api("control_device", Command.OFF) self.control_result = False diff --git a/homeassistant/components/switcher_kis/translations/cs.json b/homeassistant/components/switcher_kis/translations/cs.json new file mode 100644 index 00000000000..d3f0e37a132 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/cs.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyla nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed", + "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." + }, + "step": { + "confirm": { + "description": "Chcete za\u010d\u00edt nastavovat?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/hu.json b/homeassistant/components/switcher_kis/translations/hu.json new file mode 100644 index 00000000000..c3be866fb85 --- /dev/null +++ b/homeassistant/components/switcher_kis/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 van. Csak egyetlen konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "confirm": { + "description": "El akarja kezdeni a be\u00e1ll\u00edt\u00e1sokat?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/no.json b/homeassistant/components/switcher_kis/translations/no.json new file mode 100644 index 00000000000..b3d6b5d782e --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "step": { + "confirm": { + "description": "Vil du starte oppsettet?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index 5e8ea2f88c2..924f8aaf669 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -105,7 +105,7 @@ class FolderSensor(SensorEntity): return f"{self._short_server_id}-{self._folder_id}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state["state"] diff --git a/homeassistant/components/syncthing/translations/zh-Hans.json b/homeassistant/components/syncthing/translations/zh-Hans.json new file mode 100644 index 00000000000..87d3db5c83f --- /dev/null +++ b/homeassistant/components/syncthing/translations/zh-Hans.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52a1\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u8ba4\u8bc1\u65e0\u6548" + }, + "step": { + "user": { + "data": { + "title": "\u8bbe\u7f6e Syncthing \u96c6\u6210", + "token": "\u4ee4\u724c", + "url": "\u8fde\u63a5\u5730\u5740", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66" + } + } + } + }, + "title": "Syncthing" +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 9045b82e2ac..c422bfa6f33 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -86,11 +86,6 @@ def device_identifiers(printer: SyncThru) -> set[tuple[str, str]] | None: def device_connections(printer: SyncThru) -> set[tuple[str, str]]: """Get device connections for device registry.""" - connections = set() - try: - mac = printer.raw()["identity"]["mac_addr"] - if mac: - connections.add((dr.CONNECTION_NETWORK_MAC, mac)) - except AttributeError: - pass - return connections + if mac := printer.raw().get("identity", {}).get("mac_addr"): + return {(dr.CONNECTION_NETWORK_MAC, mac)} + return set() diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 2b559e0a15f..cc832f77f0a 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -124,7 +124,7 @@ class SyncThruSensor(CoordinatorEntity, SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measuremnt.""" return self._unit_of_measurement @@ -148,7 +148,7 @@ class SyncThruMainSensor(SyncThruSensor): self._id_suffix = "_main" @property - def state(self): + def native_value(self): """Set state to human readable version of syncthru status.""" return SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()] @@ -182,7 +182,7 @@ class SyncThruTonerSensor(SyncThruSensor): return self.syncthru.toner_status().get(self._color, {}) @property - def state(self): + def native_value(self): """Show amount of remaining toner.""" return self.syncthru.toner_status().get(self._color, {}).get("remaining") @@ -204,7 +204,7 @@ class SyncThruDrumSensor(SyncThruSensor): return self.syncthru.drum_status().get(self._color, {}) @property - def state(self): + def native_value(self): """Show amount of remaining drum.""" return self.syncthru.drum_status().get(self._color, {}).get("remaining") @@ -225,7 +225,7 @@ class SyncThruInputTraySensor(SyncThruSensor): return self.syncthru.input_tray_status().get(self._number, {}) @property - def state(self): + def native_value(self): """Display ready unless there is some error, then display error.""" tray_state = ( self.syncthru.input_tray_status().get(self._number, {}).get("newError") @@ -251,7 +251,7 @@ class SyncThruOutputTraySensor(SyncThruSensor): return self.syncthru.output_tray_status().get(self._number, {}) @property - def state(self): + def native_value(self): """Display ready unless there is some error, then display error.""" tray_state = ( self.syncthru.output_tray_status().get(self._number, {}).get("status") diff --git a/homeassistant/components/syncthru/translations/hu.json b/homeassistant/components/syncthru/translations/hu.json index 56e7c54203d..a5b645200db 100644 --- a/homeassistant/components/syncthru/translations/hu.json +++ b/homeassistant/components/syncthru/translations/hu.json @@ -4,7 +4,9 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "invalid_url": "\u00c9rv\u00e9nytelen URL" + "invalid_url": "\u00c9rv\u00e9nytelen URL", + "syncthru_not_supported": "Az eszk\u00f6z nem t\u00e1mogatja a SyncThru-t", + "unknown_state": "A nyomtat\u00f3 \u00e1llapota ismeretlen, ellen\u0151rizze az URL-t \u00e9s a h\u00e1l\u00f3zati kapcsolatot" }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/syncthru/translations/zh-Hans.json b/homeassistant/components/syncthru/translations/zh-Hans.json new file mode 100644 index 00000000000..c50e250aee9 --- /dev/null +++ b/homeassistant/components/syncthru/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown_state": "\u6253\u5370\u673a\u72b6\u6001\u672a\u77e5\uff0c\u8bf7\u9a8c\u8bc1 URL \u548c\u7f51\u7edc\u662f\u5426\u8fde\u63a5\u6b63\u5e38" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index a9ca7b4c48d..0bc88b683b7 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -686,10 +686,10 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): """Initialize the Synology DSM disk or volume entity.""" super().__init__(api, entity_type, entity_info, coordinator) self._device_id = device_id - self._device_name = None - self._device_manufacturer = None - self._device_model = None - self._device_firmware = None + self._device_name: str | None = None + self._device_manufacturer: str | None = None + self._device_model: str | None = None + self._device_firmware: str | None = None self._device_type = None if "volume" in entity_type: @@ -730,8 +730,8 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): (DOMAIN, f"{self._api.information.serial}_{self._device_id}") }, "name": f"Synology NAS ({self._device_name} - {self._device_type})", - "manufacturer": self._device_manufacturer, # type: ignore[typeddict-item] - "model": self._device_model, # type: ignore[typeddict-item] - "sw_version": self._device_firmware, # type: ignore[typeddict-item] + "manufacturer": self._device_manufacturer, + "model": self._device_model, + "sw_version": self._device_firmware, "via_device": (DOMAIN, self._api.information.serial), } diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 8341b8b121a..d609a434ae2 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -123,7 +123,9 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): """Return the camera motion detection status.""" return self.camera_data.is_motion_detection_enabled # type: ignore[no-any-return] - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" _LOGGER.debug( "SynoDSMCamera.camera_image(%s)", diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index fdbbb5678c2..1fc6ba6e09b 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -10,7 +10,10 @@ from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation -from homeassistant.components.binary_sensor import DEVICE_CLASS_SAFETY +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_SAFETY, + DEVICE_CLASS_UPDATE, +) from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -23,6 +26,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, PERCENTAGE, + TEMP_CELSIUS, ) @@ -81,8 +85,8 @@ UPGRADE_BINARY_SENSORS: dict[str, EntityInfo] = { f"{SynoCoreUpgrade.API_KEY}:update_available": { ATTR_NAME: "Update available", ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_ICON: "mdi:update", - ATTR_DEVICE_CLASS: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_UPDATE, ENTITY_ENABLE: True, ATTR_STATE_CLASS: None, }, @@ -233,7 +237,7 @@ UTILISATION_SENSORS: dict[str, EntityInfo] = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:network_up": { - ATTR_NAME: "Network Up", + ATTR_NAME: "Upload Throughput", ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_KILOBYTES_PER_SECOND, ATTR_ICON: "mdi:upload", ATTR_DEVICE_CLASS: None, @@ -241,7 +245,7 @@ UTILISATION_SENSORS: dict[str, EntityInfo] = { ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:network_down": { - ATTR_NAME: "Network Down", + ATTR_NAME: "Download Throughput", ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_KILOBYTES_PER_SECOND, ATTR_ICON: "mdi:download", ATTR_DEVICE_CLASS: None, @@ -284,7 +288,7 @@ STORAGE_VOL_SENSORS: dict[str, EntityInfo] = { }, f"{SynoStorage.API_KEY}:volume_disk_temp_avg": { ATTR_NAME: "Average Disk Temp", - ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_ICON: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, @@ -292,7 +296,7 @@ STORAGE_VOL_SENSORS: dict[str, EntityInfo] = { }, f"{SynoStorage.API_KEY}:volume_disk_temp_max": { ATTR_NAME: "Maximum Disk Temp", - ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_ICON: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: False, @@ -318,7 +322,7 @@ STORAGE_DISK_SENSORS: dict[str, EntityInfo] = { }, f"{SynoStorage.API_KEY}:disk_temp": { ATTR_NAME: "Temperature", - ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_ICON: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, @@ -329,7 +333,7 @@ STORAGE_DISK_SENSORS: dict[str, EntityInfo] = { INFORMATION_SENSORS: dict[str, EntityInfo] = { f"{SynoDSMInformation.API_KEY}:temperature": { ATTR_NAME: "temperature", - ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, ATTR_ICON: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, @@ -356,11 +360,3 @@ SURVEILLANCE_SWITCH: dict[str, EntityInfo] = { ATTR_STATE_CLASS: None, }, } - - -TEMP_SENSORS_KEYS = [ - "volume_disk_temp_avg", - "volume_disk_temp_max", - "disk_temp", - "temperature", -] diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 5942ce4a5b1..72ddb944b11 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -11,12 +11,9 @@ from homeassistant.const import ( DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, DATA_TERABYTES, - PRECISION_TENTHS, - TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.temperature import display_temp from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utcnow @@ -30,7 +27,6 @@ from .const import ( STORAGE_DISK_SENSORS, STORAGE_VOL_SENSORS, SYNO_API, - TEMP_SENSORS_KEYS, UTILISATION_SENSORS, EntityInfo, ) @@ -90,10 +86,8 @@ class SynoDSMSensor(SynologyDSMBaseEntity): """Mixin for sensor specific attributes.""" @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" - if self.entity_type in TEMP_SENSORS_KEYS: - return self.hass.config.units.temperature_unit return self._unit @@ -101,7 +95,7 @@ class SynoDSMUtilSensor(SynoDSMSensor, SensorEntity): """Representation a Synology Utilisation sensor.""" @property - def state(self) -> Any | None: + def native_value(self) -> Any | None: """Return the state.""" attr = getattr(self._api.utilisation, self.entity_type) if callable(attr): @@ -133,7 +127,7 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor, SensorEntity) """Representation a Synology Storage sensor.""" @property - def state(self) -> Any | None: + def native_value(self) -> Any | None: """Return the state.""" attr = getattr(self._api.storage, self.entity_type)(self._device_id) if attr is None: @@ -143,10 +137,6 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor, SensorEntity) if self._unit == DATA_TERABYTES: return round(attr / 1024.0 ** 4, 2) - # Temperature - if self.entity_type in TEMP_SENSORS_KEYS: - return display_temp(self.hass, attr, TEMP_CELSIUS, PRECISION_TENTHS) - return attr @@ -166,16 +156,12 @@ class SynoDSMInfoSensor(SynoDSMSensor, SensorEntity): self._last_boot: str | None = None @property - def state(self) -> Any | None: + def native_value(self) -> Any | None: """Return the state.""" attr = getattr(self._api.information, self.entity_type) if attr is None: return None - # Temperature - if self.entity_type in TEMP_SENSORS_KEYS: - return display_temp(self.hass, attr, TEMP_CELSIUS, PRECISION_TENTHS) - if self.entity_type == "uptime": # reboot happened or entity creation if self._previous_uptime is None or self._previous_uptime > attr: diff --git a/homeassistant/components/synology_dsm/translations/es.json b/homeassistant/components/synology_dsm/translations/es.json index f76ce7ab27a..7b86c248110 100644 --- a/homeassistant/components/synology_dsm/translations/es.json +++ b/homeassistant/components/synology_dsm/translations/es.json @@ -29,6 +29,10 @@ "description": "\u00bfQuieres configurar {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "description": "Raz\u00f3n: {details}", + "title": "Synology DSM Volver a autenticar la integraci\u00f3n" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/fi.json b/homeassistant/components/synology_dsm/translations/fi.json index 4f5f2cc19fa..8e1cb61abce 100644 --- a/homeassistant/components/synology_dsm/translations/fi.json +++ b/homeassistant/components/synology_dsm/translations/fi.json @@ -8,6 +8,9 @@ "data": { "otp_code": "Koodi" } + }, + "reauth": { + "description": "Syy: {details}" } } }, diff --git a/homeassistant/components/synology_dsm/translations/hu.json b/homeassistant/components/synology_dsm/translations/hu.json index 7ac507f1efa..01f02e6156d 100644 --- a/homeassistant/components/synology_dsm/translations/hu.json +++ b/homeassistant/components/synology_dsm/translations/hu.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "missing_data": "Hi\u00e1nyz\u00f3 adatok: pr\u00f3b\u00e1lja \u00fajra k\u00e9s\u0151bb vagy m\u00e1s konfigur\u00e1ci\u00f3val", + "otp_failed": "A k\u00e9tl\u00e9pcs\u0151s azonos\u00edt\u00e1s sikertelen, pr\u00f3b\u00e1lkozzon \u00faj jelsz\u00f3val", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name} ({host})", @@ -13,7 +16,8 @@ "2sa": { "data": { "otp_code": "K\u00f3d" - } + }, + "title": "Synology DSM: k\u00e9tl\u00e9pcs\u0151s azonos\u00edt\u00e1s" }, "link": { "data": { @@ -23,8 +27,17 @@ "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?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Indokl\u00e1s: {details}", + "title": "Synology DSM Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { "host": "Hoszt", @@ -42,6 +55,7 @@ "step": { "init": { "data": { + "scan_interval": "Percek a vizsg\u00e1latok k\u00f6z\u00f6tt", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s (m\u00e1sodperc)" } } diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json index c8bb60bcb3e..d1e2d084f0d 100644 --- a/homeassistant/components/synology_dsm/translations/no.json +++ b/homeassistant/components/synology_dsm/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Enheten er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -29,6 +30,14 @@ "description": "Vil du konfigurere {name} ({host})?", "title": "" }, + "reauth": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "\u00c5rsak: {details}", + "title": "Synology DSM Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/synology_dsm/translations/zh-Hans.json b/homeassistant/components/synology_dsm/translations/zh-Hans.json index b4edf8039a6..862f526c38d 100644 --- a/homeassistant/components/synology_dsm/translations/zh-Hans.json +++ b/homeassistant/components/synology_dsm/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86" + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", + "reauth_successful": "\u91cd\u9a8c\u8bc1" }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", @@ -28,13 +29,20 @@ "description": "\u60a8\u60f3\u8981\u914d\u7f6e {name} ({host}) \u5417\uff1f", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + }, "user": { "data": { "host": "\u4e3b\u673a", "password": "\u5bc6\u7801", "port": "\u7aef\u53e3", "ssl": "\u4f7f\u7528 SSL \u8bc1\u4e66", - "username": "\u7528\u6237\u540d" + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u9a8c\u8bc1 SSL \u8bc1\u4e66" }, "title": "Synology DSM" } diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 55f455ad7cc..f97b1735e21 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -1,4 +1,6 @@ """Device tracker for Synology SRM routers.""" +from __future__ import annotations + import logging import synology_srm @@ -6,7 +8,7 @@ import voluptuous as vol from homeassistant.components.device_tracker import ( DOMAIN, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) from homeassistant.const import ( @@ -26,7 +28,7 @@ DEFAULT_PORT = 8001 DEFAULT_SSL = True DEFAULT_VERIFY_SSL = False -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, @@ -106,7 +108,7 @@ class SynologySrmDeviceScanner(DeviceScanner): device = next( (result for result in self.devices if result["mac"] == device), None ) - filtered_attributes = {} + filtered_attributes: dict[str, str] = {} if not device: return filtered_attributes for attribute, alias in ATTRIBUTE_ALIAS.items(): diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 10ee4165295..f016cca798d 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from datetime import timedelta import logging import shlex @@ -22,20 +21,17 @@ from homeassistant.const import ( CONF_PORT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_validation as cv, device_registry as dr, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -61,51 +57,55 @@ SERVICE_OPEN_SCHEMA = vol.Schema( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up System Bridge from a config entry.""" - - client = Bridge( + bridge = Bridge( BridgeClient(aiohttp_client.async_get_clientsession(hass)), f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", entry.data[CONF_API_KEY], ) - async def async_update_data() -> Bridge: - """Fetch data from Bridge.""" - try: - async with async_timeout.timeout(60): - await asyncio.gather( - *[ - client.async_get_battery(), - client.async_get_cpu(), - client.async_get_filesystem(), - client.async_get_memory(), - client.async_get_network(), - client.async_get_os(), - client.async_get_processes(), - client.async_get_system(), - ] - ) - return client - except BridgeAuthenticationException as exception: - raise ConfigEntryAuthFailed from exception - except BRIDGE_CONNECTION_ERRORS as exception: - raise UpdateFailed("Could not connect to System Bridge.") from exception + try: + async with async_timeout.timeout(30): + await bridge.async_get_information() + except BridgeAuthenticationException as exception: + raise ConfigEntryAuthFailed( + f"Authentication failed for {entry.title} ({entry.data[CONF_HOST]})" + ) from exception + except BRIDGE_CONNECTION_ERRORS as exception: + raise ConfigEntryNotReady( + f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." + ) from exception - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name=f"{DOMAIN}_coordinator", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=60), - ) + coordinator = SystemBridgeDataUpdateCoordinator(hass, bridge, _LOGGER, entry=entry) + await coordinator.async_config_entry_first_refresh() + + # Wait for initial data + try: + async with async_timeout.timeout(60): + while ( + coordinator.bridge.battery is None + or coordinator.bridge.cpu is None + or coordinator.bridge.filesystem is None + or coordinator.bridge.information is None + or coordinator.bridge.memory is None + or coordinator.bridge.network is None + or coordinator.bridge.os is None + or coordinator.bridge.processes is None + or coordinator.bridge.system is None + ): + _LOGGER.debug( + "Waiting for initial data from %s (%s)", + entry.title, + entry.data[CONF_HOST], + ) + await asyncio.sleep(1) + except asyncio.TimeoutError as exception: + raise ConfigEntryNotReady( + f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + ) from exception hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator - # Fetch initial data so we have data when entities subscribe - await coordinator.async_config_entry_first_refresh() - hass.config_entries.async_setup_platforms(entry, PLATFORMS) if hass.services.has_service(DOMAIN, SERVICE_SEND_COMMAND): @@ -128,8 +128,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for entry in hass.config_entries.async_entries(DOMAIN) if entry.entry_id in device_entry.config_entries ) - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry_id] - bridge: Bridge = coordinator.data + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + bridge: Bridge = coordinator.bridge _LOGGER.debug( "Command payload: %s", @@ -166,8 +166,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for entry in hass.config_entries.async_entries(DOMAIN) if entry.entry_id in device_entry.config_entries ) - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry_id] - bridge: Bridge = coordinator.data + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + bridge: Bridge = coordinator.bridge _LOGGER.debug("Open payload: %s", {CONF_PATH: path}) try: @@ -190,14 +190,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=SERVICE_OPEN_SCHEMA, ) + # Reload entry when its updated. + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + 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: - hass.data[DOMAIN].pop(entry.entry_id) + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + + # Ensure disconnected and cleanup stop sub + await coordinator.bridge.async_close_websocket() + if coordinator.unsub: + coordinator.unsub() + + del hass.data[DOMAIN][entry.entry_id] if not hass.data[DOMAIN]: hass.services.async_remove(DOMAIN, SERVICE_SEND_COMMAND) @@ -206,13 +218,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -class BridgeEntity(CoordinatorEntity): +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the config entry when it changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class SystemBridgeEntity(CoordinatorEntity): """Defines a base System Bridge entity.""" def __init__( self, - coordinator: DataUpdateCoordinator, - bridge: Bridge, + coordinator: SystemBridgeDataUpdateCoordinator, key: str, name: str, icon: str | None, @@ -220,14 +236,13 @@ class BridgeEntity(CoordinatorEntity): ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) - self._key = f"{bridge.os.hostname}_{key}" - self._name = f"{bridge.os.hostname} {name}" + 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.os.hostname - self._default_interface = bridge.network.interfaces[ - bridge.network.interfaceDefault - ] + self._hostname = bridge.information.host + self._mac = bridge.information.mac self._manufacturer = bridge.system.system.manufacturer self._model = bridge.system.system.model self._version = bridge.system.system.version @@ -253,16 +268,14 @@ class BridgeEntity(CoordinatorEntity): return self._enabled_default -class BridgeDeviceEntity(BridgeEntity): +class SystemBridgeDeviceEntity(SystemBridgeEntity): """Defines a System Bridge device entity.""" @property def device_info(self) -> DeviceInfo: """Return device information about this System Bridge instance.""" return { - "connections": { - (dr.CONNECTION_NETWORK_MAC, self._default_interface["mac"]) - }, + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, "manufacturer": self._manufacturer, "model": self._model, "name": self._hostname, diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 488aca90bde..f6b765f8079 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -9,30 +9,29 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import BridgeDeviceEntity +from . import SystemBridgeDeviceEntity from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: """Set up System Bridge binary sensor based on a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] bridge: Bridge = coordinator.data - if bridge.battery.hasBattery: - async_add_entities([BridgeBatteryIsChargingBinarySensor(coordinator, bridge)]) + if bridge.battery and bridge.battery.hasBattery: + async_add_entities([SystemBridgeBatteryIsChargingBinarySensor(coordinator)]) -class BridgeBinarySensor(BridgeDeviceEntity, BinarySensorEntity): +class SystemBridgeBinarySensor(SystemBridgeDeviceEntity, BinarySensorEntity): """Defines a System Bridge binary sensor.""" def __init__( self, - coordinator: DataUpdateCoordinator, - bridge: Bridge, + coordinator: SystemBridgeDataUpdateCoordinator, key: str, name: str, icon: str | None, @@ -42,7 +41,7 @@ class BridgeBinarySensor(BridgeDeviceEntity, BinarySensorEntity): """Initialize System Bridge binary sensor.""" self._device_class = device_class - super().__init__(coordinator, bridge, key, name, icon, enabled_by_default) + super().__init__(coordinator, key, name, icon, enabled_by_default) @property def device_class(self) -> str | None: @@ -50,14 +49,13 @@ class BridgeBinarySensor(BridgeDeviceEntity, BinarySensorEntity): return self._device_class -class BridgeBatteryIsChargingBinarySensor(BridgeBinarySensor): +class SystemBridgeBatteryIsChargingBinarySensor(SystemBridgeBinarySensor): """Defines a Battery is charging binary sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge binary sensor.""" super().__init__( coordinator, - bridge, "battery_is_charging", "Battery Is Charging", None, diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 8402a3c1d3e..4ff887c6389 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -8,8 +8,6 @@ import async_timeout from systembridge import Bridge from systembridge.client import BridgeClient from systembridge.exceptions import BridgeAuthenticationException -from systembridge.objects.os import Os -from systembridge.objects.system import System import voluptuous as vol from homeassistant import config_entries, exceptions @@ -47,10 +45,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, hostname = data[CONF_HOST] try: async with async_timeout.timeout(30): - bridge_os: Os = await bridge.async_get_os() - if bridge_os.hostname is not None: - hostname = bridge_os.hostname - bridge_system: System = await bridge.async_get_system() + await bridge.async_get_information() + if ( + bridge.information is not None + and bridge.information.host is not None + and bridge.information.uuid is not None + ): + hostname = bridge.information.host + uuid = bridge.information.uuid except BridgeAuthenticationException as exception: _LOGGER.info(exception) raise InvalidAuth from exception @@ -58,7 +60,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, _LOGGER.info(exception) raise CannotConnect from exception - return {"hostname": hostname, "uuid": bridge_system.uuid.os} + return {"hostname": hostname, "uuid": uuid} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py new file mode 100644 index 00000000000..d34e1019a0b --- /dev/null +++ b/homeassistant/components/system_bridge/coordinator.py @@ -0,0 +1,139 @@ +"""DataUpdateCoordinator for System Bridge.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import Callable + +from systembridge import Bridge +from systembridge.exceptions import ( + BridgeAuthenticationException, + BridgeConnectionClosedException, + BridgeException, +) +from systembridge.objects.events import Event + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN + + +class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[Bridge]): + """Class to manage fetching System Bridge data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + bridge: Bridge, + LOGGER: logging.Logger, + *, + entry: ConfigEntry, + ) -> None: + """Initialize global System Bridge data updater.""" + self.bridge = bridge + self.title = entry.title + self.host = entry.data[CONF_HOST] + self.unsub: Callable | None = None + + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30) + ) + + def update_listeners(self) -> None: + """Call update on all listeners.""" + for update_callback in self._listeners: + update_callback() + + async def async_handle_event(self, event: Event): + """Handle System Bridge events from the WebSocket.""" + # No need to update anything, as everything is updated in the caller + self.logger.debug( + "New event from %s (%s): %s", self.title, self.host, event.name + ) + self.async_set_updated_data(self.bridge) + + async def _listen_for_events(self) -> None: + """Listen for events from the WebSocket.""" + try: + await self.bridge.async_send_event( + "get-data", + [ + "battery", + "cpu", + "filesystem", + "memory", + "network", + "os", + "processes", + "system", + ], + ) + await self.bridge.listen_for_events(callback=self.async_handle_event) + except BridgeConnectionClosedException as exception: + self.last_update_success = False + self.logger.info( + "Websocket Connection Closed for %s (%s). Will retry: %s", + self.title, + self.host, + exception, + ) + except BridgeException as exception: + self.last_update_success = False + self.update_listeners() + self.logger.warning( + "Exception occurred for %s (%s). Will retry: %s", + self.title, + self.host, + exception, + ) + + async def _setup_websocket(self) -> None: + """Use WebSocket for updates.""" + + try: + self.logger.debug( + "Connecting to ws://%s:%s", + self.host, + self.bridge.information.websocketPort, + ) + await self.bridge.async_connect_websocket( + self.host, self.bridge.information.websocketPort + ) + except BridgeAuthenticationException as exception: + if self.unsub: + self.unsub() + self.unsub = None + raise ConfigEntryAuthFailed() from exception + except (*BRIDGE_CONNECTION_ERRORS, ConnectionRefusedError) as exception: + if self.unsub: + self.unsub() + self.unsub = None + raise UpdateFailed( + f"Could not connect to {self.title} ({self.host})." + ) from exception + asyncio.create_task(self._listen_for_events()) + + async def close_websocket(_) -> None: + """Close WebSocket connection.""" + await self.bridge.async_close_websocket() + + # Clean disconnect WebSocket on Home Assistant shutdown + self.unsub = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, close_websocket + ) + + async def _async_update_data(self) -> Bridge: + """Update System Bridge data from WebSocket.""" + self.logger.debug( + "_async_update_data - WebSocket Connected: %s", + self.bridge.websocket_connected, + ) + if not self.bridge.websocket_connected: + await self._setup_websocket() + + return self.bridge diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 0a800657009..2f1ec0111cf 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -3,10 +3,10 @@ "name": "System Bridge", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/system_bridge", - "requirements": ["systembridge==1.1.5"], + "requirements": ["systembridge==2.0.6"], "codeowners": ["@timmo001"], "zeroconf": ["_system-bridge._udp.local."], "after_dependencies": ["zeroconf"], "quality_scale": "silver", - "iot_class": "local_polling" + "iot_class": "local_push" } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index ea7fc628e76..acfcc54f05c 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -20,10 +20,10 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import BridgeDeviceEntity +from . import SystemBridgeDeviceEntity from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator ATTR_AVAILABLE = "available" ATTR_FILESYSTEM = "filesystem" @@ -41,40 +41,38 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: """Set up System Bridge sensor based on a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - bridge: Bridge = coordinator.data + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [ - BridgeCpuSpeedSensor(coordinator, bridge), - BridgeCpuTemperatureSensor(coordinator, bridge), - BridgeCpuVoltageSensor(coordinator, bridge), + SystemBridgeCpuSpeedSensor(coordinator), + SystemBridgeCpuTemperatureSensor(coordinator), + SystemBridgeCpuVoltageSensor(coordinator), *( - BridgeFilesystemSensor(coordinator, bridge, key) - for key, _ in bridge.filesystem.fsSize.items() + SystemBridgeFilesystemSensor(coordinator, key) + for key, _ in coordinator.data.filesystem.fsSize.items() ), - BridgeMemoryFreeSensor(coordinator, bridge), - BridgeMemoryUsedSensor(coordinator, bridge), - BridgeMemoryUsedPercentageSensor(coordinator, bridge), - BridgeKernelSensor(coordinator, bridge), - BridgeOsSensor(coordinator, bridge), - BridgeProcessesLoadSensor(coordinator, bridge), - BridgeBiosVersionSensor(coordinator, bridge), + SystemBridgeMemoryFreeSensor(coordinator), + SystemBridgeMemoryUsedSensor(coordinator), + SystemBridgeMemoryUsedPercentageSensor(coordinator), + SystemBridgeKernelSensor(coordinator), + SystemBridgeOsSensor(coordinator), + SystemBridgeProcessesLoadSensor(coordinator), + SystemBridgeBiosVersionSensor(coordinator), ] - if bridge.battery.hasBattery: - entities.append(BridgeBatterySensor(coordinator, bridge)) - entities.append(BridgeBatteryTimeRemainingSensor(coordinator, bridge)) + if coordinator.data.battery.hasBattery: + entities.append(SystemBridgeBatterySensor(coordinator)) + entities.append(SystemBridgeBatteryTimeRemainingSensor(coordinator)) async_add_entities(entities) -class BridgeSensor(BridgeDeviceEntity, SensorEntity): +class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): """Defines a System Bridge sensor.""" def __init__( self, - coordinator: DataUpdateCoordinator, - bridge: Bridge, + coordinator: SystemBridgeDataUpdateCoordinator, key: str, name: str, icon: str | None, @@ -86,7 +84,7 @@ class BridgeSensor(BridgeDeviceEntity, SensorEntity): self._device_class = device_class self._unit_of_measurement = unit_of_measurement - super().__init__(coordinator, bridge, key, name, icon, enabled_by_default) + super().__init__(coordinator, key, name, icon, enabled_by_default) @property def device_class(self) -> str | None: @@ -94,19 +92,18 @@ class BridgeSensor(BridgeDeviceEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return self._unit_of_measurement -class BridgeBatterySensor(BridgeSensor): +class SystemBridgeBatterySensor(SystemBridgeSensor): """Defines a Battery sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "battery", "Battery", None, @@ -116,20 +113,19 @@ class BridgeBatterySensor(BridgeSensor): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.battery.percent -class BridgeBatteryTimeRemainingSensor(BridgeSensor): +class SystemBridgeBatteryTimeRemainingSensor(SystemBridgeSensor): """Defines the Battery Time Remaining sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "battery_time_remaining", "Battery Time Remaining", None, @@ -139,7 +135,7 @@ class BridgeBatteryTimeRemainingSensor(BridgeSensor): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data if bridge.battery.timeRemaining is None: @@ -147,14 +143,13 @@ class BridgeBatteryTimeRemainingSensor(BridgeSensor): return str(datetime.now() + timedelta(minutes=bridge.battery.timeRemaining)) -class BridgeCpuSpeedSensor(BridgeSensor): +class SystemBridgeCpuSpeedSensor(SystemBridgeSensor): """Defines a CPU speed sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "cpu_speed", "CPU Speed", "mdi:speedometer", @@ -164,20 +159,19 @@ class BridgeCpuSpeedSensor(BridgeSensor): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.cpu.currentSpeed.avg -class BridgeCpuTemperatureSensor(BridgeSensor): +class SystemBridgeCpuTemperatureSensor(SystemBridgeSensor): """Defines a CPU temperature sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "cpu_temperature", "CPU Temperature", None, @@ -187,20 +181,19 @@ class BridgeCpuTemperatureSensor(BridgeSensor): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.cpu.temperature.main -class BridgeCpuVoltageSensor(BridgeSensor): +class SystemBridgeCpuVoltageSensor(SystemBridgeSensor): """Defines a CPU voltage sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "cpu_voltage", "CPU Voltage", None, @@ -210,23 +203,22 @@ class BridgeCpuVoltageSensor(BridgeSensor): ) @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.cpu.cpu.voltage -class BridgeFilesystemSensor(BridgeSensor): +class SystemBridgeFilesystemSensor(SystemBridgeSensor): """Defines a filesystem sensor.""" def __init__( - self, coordinator: DataUpdateCoordinator, bridge: Bridge, key: str + self, coordinator: SystemBridgeDataUpdateCoordinator, key: str ) -> None: """Initialize System Bridge sensor.""" uid_key = key.replace(":", "") super().__init__( coordinator, - bridge, f"filesystem_{uid_key}", f"{key} Space Used", "mdi:harddisk", @@ -237,7 +229,7 @@ class BridgeFilesystemSensor(BridgeSensor): self._fs_key = key @property - def state(self) -> float: + def native_value(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -260,14 +252,13 @@ class BridgeFilesystemSensor(BridgeSensor): } -class BridgeMemoryFreeSensor(BridgeSensor): +class SystemBridgeMemoryFreeSensor(SystemBridgeSensor): """Defines a memory free sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "memory_free", "Memory Free", "mdi:memory", @@ -277,7 +268,7 @@ class BridgeMemoryFreeSensor(BridgeSensor): ) @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -287,14 +278,13 @@ class BridgeMemoryFreeSensor(BridgeSensor): ) -class BridgeMemoryUsedSensor(BridgeSensor): +class SystemBridgeMemoryUsedSensor(SystemBridgeSensor): """Defines a memory used sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "memory_used", "Memory Used", "mdi:memory", @@ -304,7 +294,7 @@ class BridgeMemoryUsedSensor(BridgeSensor): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -314,14 +304,13 @@ class BridgeMemoryUsedSensor(BridgeSensor): ) -class BridgeMemoryUsedPercentageSensor(BridgeSensor): +class SystemBridgeMemoryUsedPercentageSensor(SystemBridgeSensor): """Defines a memory used percentage sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "memory_used_percentage", "Memory Used %", "mdi:memory", @@ -331,7 +320,7 @@ class BridgeMemoryUsedPercentageSensor(BridgeSensor): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -341,14 +330,13 @@ class BridgeMemoryUsedPercentageSensor(BridgeSensor): ) -class BridgeKernelSensor(BridgeSensor): +class SystemBridgeKernelSensor(SystemBridgeSensor): """Defines a kernel sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "kernel", "Kernel", "mdi:devices", @@ -358,20 +346,19 @@ class BridgeKernelSensor(BridgeSensor): ) @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return bridge.os.kernel -class BridgeOsSensor(BridgeSensor): +class SystemBridgeOsSensor(SystemBridgeSensor): """Defines an OS sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "os", "Operating System", "mdi:devices", @@ -381,20 +368,19 @@ class BridgeOsSensor(BridgeSensor): ) @property - def state(self) -> str: + 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 BridgeProcessesLoadSensor(BridgeSensor): +class SystemBridgeProcessesLoadSensor(SystemBridgeSensor): """Defines a Processes Load sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "processes_load", "Load", "mdi:percent", @@ -404,7 +390,7 @@ class BridgeProcessesLoadSensor(BridgeSensor): ) @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( @@ -429,14 +415,13 @@ class BridgeProcessesLoadSensor(BridgeSensor): return attrs -class BridgeBiosVersionSensor(BridgeSensor): +class SystemBridgeBiosVersionSensor(SystemBridgeSensor): """Defines a bios version sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge) -> None: + def __init__(self, coordinator: SystemBridgeDataUpdateCoordinator) -> None: """Initialize System Bridge sensor.""" super().__init__( coordinator, - bridge, "bios_version", "BIOS Version", "mdi:chip", @@ -446,7 +431,7 @@ class BridgeBiosVersionSensor(BridgeSensor): ) @property - def state(self) -> str: + 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_health/__init__.py b/homeassistant/components/system_health/__init__.py index c8200e0e10a..651961c72ac 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -43,7 +43,7 @@ def async_register_info( SystemHealthRegistration(hass, domain).async_register_info(info_callback) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the System Health component.""" hass.components.websocket_api.async_register_command(handle_info) hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index a218e627eb6..687e9e8e521 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -331,12 +331,12 @@ class SystemMonitorSensor(SensorEntity): return self.sensor_type[SENSOR_TYPE_ICON] # type: ignore[no-any-return] @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the device.""" return self.data.state @property - def unit_of_measurement(self) -> str | None: + 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] @@ -414,20 +414,20 @@ def _update( # noqa: C901 err.pid, err.name, ) - elif type_ in ["network_out", "network_in"]: + elif type_ in ("network_out", "network_in"): counters = _net_io_counters() if data.argument in counters: counter = counters[data.argument][IO_COUNTER[type_]] state = round(counter / 1024 ** 2, 1) else: state = None - elif type_ in ["packets_out", "packets_in"]: + elif type_ in ("packets_out", "packets_in"): counters = _net_io_counters() if data.argument in counters: state = counters[data.argument][IO_COUNTER[type_]] else: state = None - elif type_ in ["throughput_network_out", "throughput_network_in"]: + elif type_ in ("throughput_network_out", "throughput_network_in"): counters = _net_io_counters() if data.argument in counters: counter = counters[data.argument][IO_COUNTER[type_]] @@ -445,7 +445,7 @@ def _update( # noqa: C901 value = counter else: state = None - elif type_ in ["ipv4_address", "ipv6_address"]: + elif type_ in ("ipv4_address", "ipv6_address"): addresses = _net_if_addrs() if data.argument in addresses: for addr in addresses[data.argument]: diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 758756e8127..8cf0ed260e8 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -3,7 +3,7 @@ "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", "requirements": ["python-tado==0.10.0"], - "codeowners": ["@michaelarnauts", "@bdraco", "@noltari"], + "codeowners": ["@michaelarnauts", "@noltari"], "config_flow": true, "homekit": { "models": ["tado", "AC02"] diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index e1219b5620b..044241f2be0 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -127,7 +127,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): return f"{self._tado.home_name} {self.home_variable}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -137,7 +137,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): return self._state_attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.home_variable in ["temperature", "outdoor temperature"]: return TEMP_CELSIUS @@ -168,10 +168,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): return if self.home_variable == "outdoor temperature": - self._state = self.hass.config.units.temperature( - self._tado_weather_data["outsideTemperature"]["celsius"], - TEMP_CELSIUS, - ) + self._state = self._tado_weather_data["outsideTemperature"]["celsius"] self._state_attributes = { "time": self._tado_weather_data["outsideTemperature"]["timestamp"], } @@ -232,7 +229,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): return f"{self.zone_name} {self.zone_variable}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -242,10 +239,10 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): return self._state_attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" if self.zone_variable == "temperature": - return self.hass.config.units.temperature_unit + return TEMP_CELSIUS if self.zone_variable == "humidity": return PERCENTAGE if self.zone_variable == "heating": @@ -277,9 +274,7 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): return if self.zone_variable == "temperature": - self._state = self.hass.config.units.temperature( - self._tado_zone_data.current_temp, TEMP_CELSIUS - ) + self._state = self._tado_zone_data.current_temp self._state_attributes = { "time": self._tado_zone_data.current_temp_timestamp, "setting": 0, # setting is used in climate device diff --git a/homeassistant/components/tado/translations/hu.json b/homeassistant/components/tado/translations/hu.json index fd8db27da5e..dfde73ce428 100644 --- a/homeassistant/components/tado/translations/hu.json +++ b/homeassistant/components/tado/translations/hu.json @@ -6,6 +6,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "no_homes": "Ehhez a tado-fi\u00f3khoz nincsenek otthonok.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { @@ -13,7 +14,19 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Csatlakozzon Tado-fi\u00f3kj\u00e1hoz" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "fallback": "A tartal\u00e9k m\u00f3d enged\u00e9lyez\u00e9se." + }, + "description": "A tartal\u00e9k m\u00f3d intelligens \u00fctemez\u00e9sre v\u00e1lt a k\u00f6vetkez\u0151 \u00fctemez\u00e9s kapcsol\u00f3n\u00e1l, miut\u00e1n manu\u00e1lisan be\u00e1ll\u00edtotta a z\u00f3n\u00e1t.", + "title": "\u00c1ll\u00edtsa be a Tado-t." } } } diff --git a/homeassistant/components/tahoma/sensor.py b/homeassistant/components/tahoma/sensor.py index 47e6d300414..35a51b03805 100644 --- a/homeassistant/components/tahoma/sensor.py +++ b/homeassistant/components/tahoma/sensor.py @@ -35,12 +35,12 @@ class TahomaSensor(TahomaDevice, SensorEntity): super().__init__(tahoma_device, controller) @property - def state(self): + def native_value(self): """Return the name of the sensor.""" return self.current_value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" if self.tahoma_device.type == "io:TemperatureIOSystemSensor": return TEMP_CELSIUS diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 379819cf65e..93794ce0c50 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -81,7 +81,7 @@ class TankUtilitySensor(SensorEntity): return self._device @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -91,7 +91,7 @@ class TankUtilitySensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the device.""" return self._unit_of_measurement diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 5c1898e02a9..166e1da7060 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -110,12 +110,12 @@ class FuelPriceSensor(CoordinatorEntity, SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return unit of measurement.""" return CURRENCY_EURO @property - def state(self): + def native_value(self): """Return the state of the device.""" # key Fuel_type is not available when the fuel station is closed, use "get" instead of "[]" to avoid exceptions return self.coordinator.data[self._station_id].get(self._fuel_type) diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index e1621f2c126..435604b4bdd 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -1,12 +1,12 @@ """Config flow for Tasmota.""" from __future__ import annotations -from typing import Any, cast +from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.mqtt import ReceiveMessage, valid_subscribe_topic +from homeassistant.components.mqtt import valid_subscribe_topic from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.typing import DiscoveryInfoType @@ -29,16 +29,17 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) - # Validate the topic, will throw if it fails - prefix = cast(ReceiveMessage, discovery_info).subscribed_topic - if prefix.endswith("/#"): - prefix = prefix[:-2] - try: - valid_subscribe_topic(f"{prefix}/#") - except vol.Invalid: + # Validate the message, abort if it fails + if not discovery_info["topic"].endswith("/config"): + # Not a Tasmota discovery message + return self.async_abort(reason="invalid_discovery_info") + if not discovery_info["payload"]: + # Empty payload, the Tasmota is not configured for native discovery return self.async_abort(reason="invalid_discovery_info") - self._prefix = prefix + # "tasmota/discovery/#" is hardcoded in Tasmota's manifest + assert discovery_info["subscribed_topic"] == "tasmota/discovery/#" + self._prefix = "tasmota/discovery" return await self.async_step_confirm() diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index eb95ca2bf64..b3be1fbd2cc 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Callable +from typing import Any, Callable import attr from hatasmota.models import DiscoveryHashType @@ -259,7 +259,9 @@ async def async_remove_triggers(hass: HomeAssistant, device_id: str) -> None: device_trigger.remove_update_signal() -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for a Tasmota device.""" triggers: list[dict[str, str]] = [] diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index b756d656921..39ee97d1648 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime -import logging from typing import Any from hatasmota import const as hc, sensor as tasmota_sensor, status_sensor @@ -10,7 +9,11 @@ from hatasmota.entity import TasmotaEntity as HATasmotaEntity from hatasmota.models import DiscoveryHashType from homeassistant.components import sensor -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -49,14 +52,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt as dt_util from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate -_LOGGER = logging.getLogger(__name__) - DEVICE_CLASS = "device_class" STATE_CLASS = "state_class" ICON = "icon" @@ -121,7 +121,7 @@ SENSOR_DEVICE_CLASS_ICON_MAP = { hc.SENSOR_TODAY: {DEVICE_CLASS: DEVICE_CLASS_ENERGY}, hc.SENSOR_TOTAL: { DEVICE_CLASS: DEVICE_CLASS_ENERGY, - STATE_CLASS: STATE_CLASS_MEASUREMENT, + STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, hc.SENSOR_TOTAL_START_TIME: {ICON: "mdi:progress-clock"}, hc.SENSOR_TVOC: {ICON: "mdi:air-filter"}, @@ -188,7 +188,6 @@ async def async_setup_entry( class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): """Representation of a Tasmota sensor.""" - _attr_last_reset = None _tasmota_entity: tasmota_sensor.TasmotaSensor def __init__(self, **kwds: Any) -> None: @@ -212,17 +211,6 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): self._state_timestamp = state else: self._state = state - if "last_reset" in kwargs: - try: - last_reset_dt = dt_util.parse_datetime(kwargs["last_reset"]) - last_reset = dt_util.as_utc(last_reset_dt) if last_reset_dt else None - if last_reset is None: - raise ValueError - self._attr_last_reset = last_reset - except ValueError: - _LOGGER.warning( - "Invalid last_reset timestamp '%s'", kwargs["last_reset"] - ) self.async_write_ha_state() @property @@ -258,7 +246,7 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): return class_or_icon.get(ICON) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" if self._state_timestamp and self.device_class == DEVICE_CLASS_TIMESTAMP: return self._state_timestamp.isoformat() @@ -270,6 +258,6 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): return True @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return SENSOR_UNIT_MAP.get(self._tasmota_entity.unit, self._tasmota_entity.unit) diff --git a/homeassistant/components/tasmota/translations/nl.json b/homeassistant/components/tasmota/translations/nl.json index c099d376920..da16eb72bc3 100644 --- a/homeassistant/components/tasmota/translations/nl.json +++ b/homeassistant/components/tasmota/translations/nl.json @@ -4,7 +4,7 @@ "single_instance_allowed": "Is al geconfigureerd. Er is maar een configuratie mogelijk" }, "error": { - "invalid_discovery_topic": "Ongeldig onderwerpvoorvoegsel voor ontdekken" + "invalid_discovery_topic": "Invalid discovery topic prefix." }, "step": { "config": { diff --git a/homeassistant/components/tautulli/manifest.json b/homeassistant/components/tautulli/manifest.json index cb2e38ebd6d..d413e477397 100644 --- a/homeassistant/components/tautulli/manifest.json +++ b/homeassistant/components/tautulli/manifest.json @@ -2,7 +2,7 @@ "domain": "tautulli", "name": "Tautulli", "documentation": "https://www.home-assistant.io/integrations/tautulli", - "requirements": ["pytautulli==0.5.0"], + "requirements": ["pytautulli==21.8.1"], "codeowners": ["@ludeeus"], "iot_class": "local_polling" } diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index c50efb00ed7..16b58b206aa 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,7 +1,7 @@ """A platform which allows you to get information from Tautulli.""" from datetime import timedelta -from pytautulli import Tautulli +from pytautulli import PyTautulli import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -60,10 +60,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= session = async_get_clientsession(hass, verify_ssl) tautulli = TautulliData( - Tautulli(host, port, api_key, hass.loop, session, use_ssl, path) + PyTautulli( + api_token=api_key, + hostname=host, + session=session, + verify_ssl=verify_ssl, + port=port, + ssl=use_ssl, + base_api_path=path, + ) ) - if not await tautulli.test_connection(): + await tautulli.async_update() + if not tautulli.activity or not tautulli.home_stats or not tautulli.users: raise PlatformNotReady sensor = [TautulliSensor(tautulli, name, monitored_conditions, user)] @@ -88,25 +97,52 @@ class TautulliSensor(SensorEntity): async def async_update(self): """Get the latest data from the Tautulli API.""" await self.tautulli.async_update() - self.home = self.tautulli.api.home_data - self.sessions = self.tautulli.api.session_data - self._attributes["Top Movie"] = self.home.get("movie") - self._attributes["Top TV Show"] = self.home.get("tv") - self._attributes["Top User"] = self.home.get("user") - for key in self.sessions: - if "sessions" not in key: - self._attributes[key] = self.sessions[key] - for user in self.tautulli.api.users: - if self.usernames is None or user in self.usernames: - userdata = self.tautulli.api.user_data - self._attributes[user] = {} - self._attributes[user]["Activity"] = userdata[user]["Activity"] - if self.monitored_conditions: - for key in self.monitored_conditions: - try: - self._attributes[user][key] = userdata[user][key] - except (KeyError, TypeError): - self._attributes[user][key] = "" + if ( + not self.tautulli.activity + or not self.tautulli.home_stats + or not self.tautulli.users + ): + return + + 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, + } + + for stat in self.tautulli.home_stats: + if stat.stat_id == "top_movies": + self._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 + ) + elif stat.stat_id == "top_users": + self._attributes["Top User"] = stat.rows[0].user if stat.rows else None + + for user in self.tautulli.users: + if ( + self.usernames + and user.username not in self.usernames + or user.username == "Local" + ): + continue + self._attributes.setdefault(user.username, {})["Activity"] = None + + for session in self.tautulli.activity.sessions: + if not self._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) @property def name(self): @@ -114,9 +150,11 @@ class TautulliSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - return self.sessions.get("stream_count") + if not self.tautulli.activity: + return 0 + return self.tautulli.activity.stream_count @property def icon(self): @@ -124,7 +162,7 @@ class TautulliSensor(SensorEntity): return "mdi:plex" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return "Watching" @@ -140,14 +178,13 @@ class TautulliData: 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.""" - await self.api.get_data() - - async def test_connection(self): - """Test connection to Tautulli.""" - await self.api.test_connection() - connection_status = self.api.connection - return connection_status + 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() diff --git a/homeassistant/components/tcp/sensor.py b/homeassistant/components/tcp/sensor.py index d282974fd4c..4db511e1f57 100644 --- a/homeassistant/components/tcp/sensor.py +++ b/homeassistant/components/tcp/sensor.py @@ -31,11 +31,11 @@ class TcpSensor(TcpEntity, SensorEntity): """Implementation of a TCP socket based sensor.""" @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the device.""" return self._state @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" return self._config[CONF_UNIT_OF_MEASUREMENT] diff --git a/homeassistant/components/ted5000/sensor.py b/homeassistant/components/ted5000/sensor.py index 6732014c747..a7162ee9c63 100644 --- a/homeassistant/components/ted5000/sensor.py +++ b/homeassistant/components/ted5000/sensor.py @@ -79,12 +79,12 @@ class Ted5000Sensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @property - def state(self): + def native_value(self): """Return the state of the resources.""" with suppress(KeyError): return self._gateway.data[self._mtu][self._unit] diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 02629e695fc..84b7249d203 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -286,7 +286,7 @@ def load_data( _LOGGER.warning("Can't load data in %s after %s retries", url, retry_num) elif filepath is not None: if hass.config.is_allowed_path(filepath): - return open(filepath, "rb") # pylint: disable=consider-using-with + return open(filepath, "rb") _LOGGER.warning("'%s' are not secure to load data from!", filepath) else: diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index a86b487afd2..35fc6809523 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -111,7 +111,7 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): return "{} {}".format(super().name, self.quantity_name or "").strip() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if not self.available: return None @@ -129,7 +129,7 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): return SENSOR_TYPES[self._type][0] if self._type in SENSOR_TYPES else None @property - def unit_of_measurement(self): + 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 diff --git a/homeassistant/components/tellduslive/translations/en_GB.json b/homeassistant/components/tellduslive/translations/en_GB.json new file mode 100644 index 00000000000..4cd830a3ced --- /dev/null +++ b/homeassistant/components/tellduslive/translations/en_GB.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL.", + "unknown_authorize_url_generation": "Unknown error generating an authorise URL." + }, + "step": { + "auth": { + "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorise **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/translations/he.json b/homeassistant/components/tellduslive/translations/he.json index db5a0aad8d9..d19fa6d3d31 100644 --- a/homeassistant/components/tellduslive/translations/he.json +++ b/homeassistant/components/tellduslive/translations/he.json @@ -13,7 +13,6 @@ "data": { "host": "\u05de\u05d0\u05e8\u05d7" }, - "description": "\u05e8\u05d9\u05e7", "title": "\u05d1\u05d7\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05e7\u05e6\u05d4." } } diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index 599c19388d6..74548f94d1b 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -154,12 +154,12 @@ class TellstickSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._value @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py index 7d447d3f9ea..0274043c089 100644 --- a/homeassistant/components/temper/sensor.py +++ b/homeassistant/components/temper/sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import ( CONF_OFFSET, DEVICE_CLASS_TEMPERATURE, DEVICE_DEFAULT_NAME, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) _LOGGER = logging.getLogger(__name__) @@ -35,15 +35,14 @@ def get_temper_devices(): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Temper sensors.""" - temp_unit = hass.config.units.temperature_unit - name = config.get(CONF_NAME) + prefix = name = config[CONF_NAME] scaling = {"scale": config.get(CONF_SCALE), "offset": config.get(CONF_OFFSET)} temper_devices = get_temper_devices() for idx, dev in enumerate(temper_devices): if idx != 0: - name = f"{name}_{idx!s}" - TEMPER_SENSORS.append(TemperSensor(dev, temp_unit, name, scaling)) + name = f"{prefix}_{idx!s}" + TEMPER_SENSORS.append(TemperSensor(dev, name, scaling)) add_entities(TEMPER_SENSORS) @@ -61,30 +60,16 @@ def reset_devices(): class TemperSensor(SensorEntity): """Representation of a Temper temperature sensor.""" - def __init__(self, temper_device, temp_unit, name, scaling): + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_native_unit_of_measurement = TEMP_CELSIUS + + def __init__(self, temper_device, name, scaling): """Initialize the sensor.""" - self.temp_unit = temp_unit self.scale = scaling["scale"] self.offset = scaling["offset"] - self.current_value = None - self._name = name self.set_temper_device(temper_device) - self._attr_device_class = DEVICE_CLASS_TEMPERATURE - @property - def name(self): - """Return the name of the temperature sensor.""" - return self._name - - @property - def state(self): - """Return the state of the entity.""" - return self.current_value - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self.temp_unit + self._attr_name = name def set_temper_device(self, temper_device): """Assign the underlying device for this sensor.""" @@ -96,11 +81,8 @@ class TemperSensor(SensorEntity): def update(self): """Retrieve latest state.""" try: - format_str = ( - "fahrenheit" if self.temp_unit == TEMP_FAHRENHEIT else "celsius" - ) - sensor_value = self.temper_device.get_temperature(format_str) - self.current_value = round(sensor_value, 1) + sensor_value = self.temper_device.get_temperature("celsius") + self._attr_native_value = round(sensor_value, 1) except OSError: _LOGGER.error( "Failed to get temperature. The device address may" diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 4d316388eae..9a4c3f93f63 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -358,7 +358,7 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity): for key in (CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF): if isinstance(config.get(key), template.Template): - self._to_render.append(key) + self._to_render_simple.append(key) self._parse_result.add(key) self._delay_cancel = None diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 165420bf404..4bcda6b6752 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -4,13 +4,20 @@ import logging import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config import async_log_exception, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import async_validate_trigger_config -from . import binary_sensor as binary_sensor_platform, sensor as sensor_platform +from . import ( + binary_sensor as binary_sensor_platform, + number as number_platform, + select as select_platform, + sensor as sensor_platform, +) from .const import CONF_TRIGGER, DOMAIN PACKAGE_MERGE_HINT = "list" @@ -19,6 +26,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(NUMBER_DOMAIN): vol.All( + cv.ensure_list, [number_platform.NUMBER_SCHEMA] + ), vol.Optional(SENSOR_DOMAIN): vol.All( cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] ), @@ -31,6 +41,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA ), + vol.Optional(SELECT_DOMAIN): vol.All( + cv.ensure_list, [select_platform.SELECT_SCHEMA] + ), } ) diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 31896e930e4..0309321afbc 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -15,6 +15,8 @@ PLATFORMS = [ "fan", "light", "lock", + "number", + "select", "sensor", "switch", "vacuum", diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 563a9af2849..7289eeb72e6 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -465,7 +465,7 @@ class TemplateFan(TemplateEntity, FanEntity): # Validate state if result in _VALID_STATES: self._state = result - elif result in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif result in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._state = None else: _LOGGER.error( @@ -529,7 +529,7 @@ class TemplateFan(TemplateEntity, FanEntity): self._speed = speed self._percentage = self.speed_to_percentage(speed) self._preset_mode = speed if speed in self.preset_modes else None - elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif speed in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._speed = None self._percentage = 0 self._preset_mode = None @@ -573,7 +573,7 @@ class TemplateFan(TemplateEntity, FanEntity): self._speed = preset_mode self._percentage = None self._preset_mode = preset_mode - elif preset_mode in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif preset_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._speed = None self._percentage = None self._preset_mode = None @@ -594,7 +594,7 @@ class TemplateFan(TemplateEntity, FanEntity): self._oscillating = True elif oscillating == "False" or oscillating is False: self._oscillating = False - elif oscillating in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif oscillating in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._oscillating = None else: _LOGGER.error( @@ -608,7 +608,7 @@ class TemplateFan(TemplateEntity, FanEntity): # Validate direction if direction in _VALID_DIRECTIONS: self._direction = direction - elif direction in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + elif direction in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._direction = None else: _LOGGER.error( diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py new file mode 100644 index 00000000000..a7737c31246 --- /dev/null +++ b/homeassistant/components/template/number.py @@ -0,0 +1,245 @@ +"""Support for numbers which integrates with other components.""" +from __future__ import annotations + +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.number import NumberEntity +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE, + DEFAULT_MAX_VALUE, + DEFAULT_MIN_VALUE, + DOMAIN as NUMBER_DOMAIN, +) +from homeassistant.components.template import TriggerUpdateCoordinator +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID +from homeassistant.core import Config, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.script import Script +from homeassistant.helpers.template import Template, TemplateError + +from .const import CONF_AVAILABILITY +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_SET_VALUE = "set_value" + +DEFAULT_NAME = "Template Number" +DEFAULT_OPTIMISTIC = False + +NUMBER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_STEP): cv.template, + vol.Optional(ATTR_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Optional(ATTR_MAX, default=DEFAULT_MAX_VALUE): cv.template, + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def _async_create_entities( + hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None +) -> list[TemplateNumber]: + """Create the Template number.""" + entities = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + entities.append( + TemplateNumber( + hass, + definition[CONF_NAME], + definition[CONF_STATE], + definition.get(CONF_AVAILABILITY), + definition[CONF_SET_VALUE], + definition[ATTR_STEP], + definition[ATTR_MIN], + definition[ATTR_MAX], + definition[CONF_OPTIMISTIC], + unique_id, + ) + ) + return entities + + +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the template number.""" + if discovery_info is None: + _LOGGER.warning( + "Template number entities can only be configured under template:" + ) + return + + if "coordinator" in discovery_info: + async_add_entities( + TriggerNumberEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + + async_add_entities( + await _async_create_entities( + hass, discovery_info["entities"], discovery_info["unique_id"] + ) + ) + + +class TemplateNumber(TemplateEntity, NumberEntity): + """Representation of a template number.""" + + def __init__( + self, + hass: HomeAssistant, + name_template: Template, + value_template: Template, + availability_template: Template | None, + command_set_value: dict[str, Any], + step_template: Template, + minimum_template: Template | None, + maximum_template: Template | None, + optimistic: bool, + unique_id: str | None, + ) -> None: + """Initialize the number.""" + super().__init__(availability_template=availability_template) + self._attr_name = DEFAULT_NAME + self._name_template = name_template + name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = name_template.async_render(parse_result=False) + self._value_template = value_template + domain = __name__.split(".")[-2] + self._command_set_value = Script( + hass, command_set_value, self._attr_name, domain + ) + self._step_template = step_template + self._min_value_template = minimum_template + self._max_value_template = maximum_template + self._attr_assumed_state = self._optimistic = optimistic + self._attr_unique_id = unique_id + self._attr_value = None + self._attr_step = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + if self._name_template and not self._name_template.is_static: + self.add_template_attribute("_attr_name", self._name_template, cv.string) + self.add_template_attribute( + "_attr_value", + self._value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_step", + self._step_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + if self._min_value_template is not None: + self.add_template_attribute( + "_attr_min_value", + self._min_value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + if self._max_value_template is not None: + self.add_template_attribute( + "_attr_max_value", + self._max_value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + await super().async_added_to_hass() + + async def async_set_value(self, value: float) -> None: + """Set value of the number.""" + if self._optimistic: + self._attr_value = value + self.async_write_ha_state() + await self._command_set_value.async_run( + {ATTR_VALUE: value}, context=self._context + ) + + +class TriggerNumberEntity(TriggerEntity, NumberEntity): + """Number entity based on trigger data.""" + + domain = NUMBER_DOMAIN + extra_template_keys = ( + CONF_STATE, + ATTR_STEP, + ATTR_MIN, + ATTR_MAX, + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: dict, + ) -> None: + """Initialize the entity.""" + super().__init__(hass, coordinator, config) + domain = __name__.split(".")[-2] + self._command_set_value = Script( + hass, + config[CONF_SET_VALUE], + self._rendered.get(CONF_NAME, DEFAULT_NAME), + domain, + ) + + @property + def value(self) -> float | None: + """Return the currently selected option.""" + return vol.Any(vol.Coerce(float), None)(self._rendered.get(CONF_STATE)) + + @property + def min_value(self) -> int: + """Return the minimum value.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_MIN, super().min_value) + ) + + @property + def max_value(self) -> int: + """Return the maximum value.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_MAX, super().max_value) + ) + + @property + def step(self) -> int: + """Return the increment/decrement step.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_STEP, super().step) + ) + + async def async_set_value(self, value: float) -> None: + """Set value of the number.""" + if self._config[CONF_OPTIMISTIC]: + self._attr_value = value + self.async_write_ha_state() + await self._command_set_value.async_run( + {ATTR_VALUE: value}, context=self._context + ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py new file mode 100644 index 00000000000..944c80cbfa4 --- /dev/null +++ b/homeassistant/components/template/select.py @@ -0,0 +1,199 @@ +"""Support for selects which integrates with other components.""" +from __future__ import annotations + +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.select import SelectEntity +from homeassistant.components.select.const import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, +) +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID +from homeassistant.core import Config, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.script import Script +from homeassistant.helpers.template import Template, TemplateError + +from . import TriggerUpdateCoordinator +from .const import CONF_AVAILABILITY +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_SELECT_OPTION = "select_option" + +DEFAULT_NAME = "Template Select" +DEFAULT_OPTIMISTIC = False + +SELECT_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_OPTIONS): cv.template, + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def _async_create_entities( + hass: HomeAssistant, entities: list[dict[str, Any]], unique_id_prefix: str | None +) -> list[TemplateSelect]: + """Create the Template select.""" + for entity in entities: + unique_id = entity.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + + return [ + TemplateSelect( + hass, + entity.get(CONF_NAME, DEFAULT_NAME), + entity[CONF_STATE], + entity.get(CONF_AVAILABILITY), + entity[CONF_SELECT_OPTION], + entity[ATTR_OPTIONS], + entity.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), + unique_id, + ) + ] + + +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the template select.""" + if discovery_info is None: + _LOGGER.warning( + "Template number entities can only be configured under template:" + ) + return + + if "coordinator" in discovery_info: + async_add_entities( + TriggerSelectEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + + async_add_entities( + await _async_create_entities( + hass, discovery_info["entities"], discovery_info["unique_id"] + ) + ) + + +class TemplateSelect(TemplateEntity, SelectEntity): + """Representation of a template select.""" + + def __init__( + self, + hass: HomeAssistant, + name_template: Template | None, + value_template: Template, + availability_template: Template | None, + command_select_option: dict[str, Any], + options_template: Template, + optimistic: bool, + unique_id: str | None, + ) -> None: + """Initialize the select.""" + super().__init__(availability_template=availability_template) + self._attr_name = DEFAULT_NAME + name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = name_template.async_render(parse_result=False) + self._name_template = name_template + self._value_template = value_template + domain = __name__.split(".")[-2] + self._command_select_option = Script( + hass, command_select_option, self._attr_name, domain + ) + self._options_template = options_template + self._attr_assumed_state = self._optimistic = optimistic + self._attr_unique_id = unique_id + self._attr_options = None + self._attr_current_option = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.add_template_attribute( + "_attr_current_option", + self._value_template, + validator=cv.string, + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_options", + self._options_template, + validator=vol.All(cv.ensure_list, [cv.string]), + none_on_template_error=True, + ) + if self._name_template and not self._name_template.is_static: + self.add_template_attribute("_attr_name", self._name_template, cv.string) + await super().async_added_to_hass() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if self._optimistic: + self._attr_current_option = option + self.async_write_ha_state() + await self._command_select_option.async_run( + {ATTR_OPTION: option}, context=self._context + ) + + +class TriggerSelectEntity(TriggerEntity, SelectEntity): + """Select entity based on trigger data.""" + + domain = SELECT_DOMAIN + extra_template_keys = (CONF_STATE,) + extra_template_keys_complex = (ATTR_OPTIONS,) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: dict, + ) -> None: + """Initialize the entity.""" + super().__init__(hass, coordinator, config) + domain = __name__.split(".")[-2] + self._command_select_option = Script( + hass, + config[CONF_SELECT_OPTION], + self._rendered.get(CONF_NAME, DEFAULT_NAME), + domain, + ) + + @property + def current_option(self) -> str | None: + """Return the currently selected option.""" + return self._rendered.get(CONF_STATE) + + @property + def options(self) -> list[str]: + """Return the list of available options.""" + return self._rendered.get(ATTR_OPTIONS, []) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if self._config[CONF_OPTIMISTIC]: + self._attr_current_option = option + self.async_write_ha_state() + await self._command_select_option.async_run( + {ATTR_OPTION: option}, context=self._context + ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index a887890510a..d51b18e294b 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -255,7 +255,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): except template.TemplateError: pass - self._attr_unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._template = state_template self._attr_device_class = device_class self._attr_state_class = state_class @@ -264,7 +264,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): async def async_added_to_hass(self): """Register callbacks.""" self.add_template_attribute( - "_attr_state", self._template, None, self._update_state + "_attr_native_value", self._template, None, self._update_state ) if self._friendly_name_template and not self._friendly_name_template.is_static: self.add_template_attribute("_attr_name", self._friendly_name_template) @@ -274,7 +274,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): @callback def _update_state(self, result): super()._update_state(result) - self._attr_state = None if isinstance(result, TemplateError) else result + self._attr_native_value = None if isinstance(result, TemplateError) else result class TriggerSensorEntity(TriggerEntity, SensorEntity): @@ -284,7 +284,7 @@ class TriggerSensorEntity(TriggerEntity, SensorEntity): extra_template_keys = (CONF_STATE,) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return state of the sensor.""" return self._rendered.get(CONF_STATE) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index ee9c60293df..84ad4072b66 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -23,6 +23,7 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): domain = "" extra_template_keys: tuple | None = None + extra_template_keys_complex: tuple | None = None def __init__( self, @@ -43,7 +44,8 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): self._config = config self._static_rendered = {} - self._to_render = [] + self._to_render_simple = [] + self._to_render_complex = [] for itm in ( CONF_NAME, @@ -57,10 +59,13 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): if config[itm].is_static: self._static_rendered[itm] = config[itm].template else: - self._to_render.append(itm) + self._to_render_simple.append(itm) if self.extra_template_keys is not None: - self._to_render.extend(self.extra_template_keys) + self._to_render_simple.extend(self.extra_template_keys) + + if self.extra_template_keys_complex is not None: + self._to_render_complex.extend(self.extra_template_keys_complex) # We make a copy so our initial render is 'unknown' and not 'unavailable' self._rendered = dict(self._static_rendered) @@ -124,12 +129,18 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): try: rendered = dict(self._static_rendered) - for key in self._to_render: + for key in self._to_render_simple: rendered[key] = self._config[key].async_render( self.coordinator.data["run_variables"], parse_result=key in self._parse_result, ) + for key in self._to_render_complex: + rendered[key] = template.render_complex( + self._config[key], + self.coordinator.data["run_variables"], + ) + if CONF_ATTRIBUTES in self._config: rendered[CONF_ATTRIBUTES] = template.render_complex( self._config[CONF_ATTRIBUTES], diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py index 40c7aa8548d..60e3e19047d 100644 --- a/homeassistant/components/tesla/sensor.py +++ b/homeassistant/components/tesla/sensor.py @@ -38,13 +38,13 @@ class TeslaSensor(TeslaDevice, SensorEntity): self._unique_id = f"{super().unique_id}_{self.type}" @property - def state(self) -> float | None: + 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"]: + 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() @@ -57,7 +57,7 @@ class TeslaSensor(TeslaDevice, SensorEntity): return self.tesla_device.get_value() @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit_of_measurement of the device.""" units = self.tesla_device.measurement if units == "F": diff --git a/homeassistant/components/tesla/translations/es.json b/homeassistant/components/tesla/translations/es.json index 54fbfd1a21d..8211e806741 100644 --- a/homeassistant/components/tesla/translations/es.json +++ b/homeassistant/components/tesla/translations/es.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "C\u00f3digo MFA (opcional)", "password": "Contrase\u00f1a", "username": "Correo electr\u00f3nico" }, diff --git a/homeassistant/components/tesla/translations/fi.json b/homeassistant/components/tesla/translations/fi.json new file mode 100644 index 00000000000..b7ed0a4bd5c --- /dev/null +++ b/homeassistant/components/tesla/translations/fi.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mfa": "MFA-koodi (valinnainen)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/hu.json b/homeassistant/components/tesla/translations/hu.json index a4622ce7efa..75a93566df5 100644 --- a/homeassistant/components/tesla/translations/hu.json +++ b/homeassistant/components/tesla/translations/hu.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA k\u00f3d (opcion\u00e1lis)", "password": "Jelsz\u00f3", "username": "E-mail" }, @@ -24,6 +25,7 @@ "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" } } diff --git a/homeassistant/components/tesla/translations/no.json b/homeassistant/components/tesla/translations/no.json index ce706640636..11e49486107 100644 --- a/homeassistant/components/tesla/translations/no.json +++ b/homeassistant/components/tesla/translations/no.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA -kode (valgfritt)", "password": "Passord", "username": "E-post" }, diff --git a/homeassistant/components/tesla/translations/zh-Hans.json b/homeassistant/components/tesla/translations/zh-Hans.json new file mode 100644 index 00000000000..35635ce3be3 --- /dev/null +++ b/homeassistant/components/tesla/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mfa": "MFA \u4ee3\u7801\uff08\u53ef\u9009\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 1bdbbc5fcc3..2e4ef6e56ec 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -120,7 +120,7 @@ class ThermoworksSmokeSensor(SensorEntity): return self._unique_id @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -130,7 +130,7 @@ class ThermoworksSmokeSensor(SensorEntity): return self._attributes @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this sensor.""" return self._unit_of_measurement @@ -160,7 +160,7 @@ class ThermoworksSmokeSensor(SensorEntity): } # set extended attributes for main probe sensors - if self.type in [PROBE_1, PROBE_2]: + if self.type in (PROBE_1, PROBE_2): for key, val in values.items(): # add all attributes that don't contain any probe name # or contain a matching probe name diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 2e139eae63d..089d1eda2ee 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -76,7 +76,7 @@ class TtnDataSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the entity.""" if self._ttn_data_storage.data is not None: try: @@ -86,7 +86,7 @@ class TtnDataSensor(SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return self._unit_of_measurement diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index fa1dfd5988c..588fc212138 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -1,22 +1,39 @@ """Support for ThinkingCleaner sensors.""" +from __future__ import annotations + from datetime import timedelta from pythinkingcleaner import Discovery, ThinkingCleaner import voluptuous as vol from homeassistant import util -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_HOST, PERCENTAGE import homeassistant.helpers.config_validation as cv MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) -SENSOR_TYPES = { - "battery": ["Battery", PERCENTAGE, "mdi:battery"], - "state": ["State", None, None], - "capacity": ["Capacity", None, None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + SensorEntityDescription( + key="state", + name="State", + ), + SensorEntityDescription( + key="capacity", + name="Capacity", + ), +) STATES = { "st_base": "On homebase: Not Charging", @@ -64,53 +81,34 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for device_object in devices: device_object.update() - dev = [] - for device in devices: - for type_name in SENSOR_TYPES: - dev.append(ThinkingCleanerSensor(device, type_name, update_devices)) + entities = [ + ThinkingCleanerSensor(device, update_devices, description) + for device in devices + for description in SENSOR_TYPES + ] - add_entities(dev) + add_entities(entities) class ThinkingCleanerSensor(SensorEntity): """Representation of a ThinkingCleaner Sensor.""" - def __init__(self, tc_object, sensor_type, update_devices): + def __init__(self, tc_object, update_devices, description: SensorEntityDescription): """Initialize the ThinkingCleaner.""" - self.type = sensor_type - + self.entity_description = description self._tc_object = tc_object self._update_devices = update_devices - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._tc_object.name} {SENSOR_TYPES[self.type][0]}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_name = f"{tc_object.name} {description.name}" def update(self): """Update the sensor.""" self._update_devices() - if self.type == "battery": - self._state = self._tc_object.battery - elif self.type == "state": - self._state = STATES[self._tc_object.status] - elif self.type == "capacity": - self._state = self._tc_object.capacity + sensor_type = self.entity_description.key + if sensor_type == "battery": + self._attr_native_value = self._tc_object.battery + elif sensor_type == "state": + self._attr_native_value = STATES[self._tc_object.status] + elif sensor_type == "capacity": + self._attr_native_value = self._tc_object.capacity diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 6a1d20f9509..75cfc51a511 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -1,4 +1,6 @@ """Support for ThinkingCleaner switches.""" +from __future__ import annotations + from datetime import timedelta import time @@ -6,10 +8,13 @@ from pythinkingcleaner import Discovery, ThinkingCleaner import voluptuous as vol from homeassistant import util -from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.components.switch import ( + PLATFORM_SCHEMA, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import ToggleEntity MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) @@ -17,11 +22,20 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_TO_WAIT = timedelta(seconds=5) MIN_TIME_TO_LOCK_UPDATE = 5 -SWITCH_TYPES = { - "clean": ["Clean", None, None], - "dock": ["Dock", None, None], - "find": ["Find", None, None], -} +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="clean", + name="Clean", + ), + SwitchEntityDescription( + key="dock", + name="Dock", + ), + SwitchEntityDescription( + key="find", + name="Find", + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_HOST): cv.string}) @@ -41,28 +55,33 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for device_object in devices: device_object.update() - dev = [] - for device in devices: - for type_name in SWITCH_TYPES: - dev.append(ThinkingCleanerSwitch(device, type_name, update_devices)) + entities = [ + ThinkingCleanerSwitch(device, update_devices, description) + for device in devices + for description in SWITCH_TYPES + ] - add_entities(dev) + add_entities(entities) -class ThinkingCleanerSwitch(ToggleEntity): +class ThinkingCleanerSwitch(SwitchEntity): """ThinkingCleaner Switch (dock, clean, find me).""" - def __init__(self, tc_object, switch_type, update_devices): + def __init__(self, tc_object, update_devices, description: SwitchEntityDescription): """Initialize the ThinkingCleaner.""" - self.type = switch_type + self.entity_description = description self._update_devices = update_devices self._tc_object = tc_object - self._state = self._tc_object.is_cleaning if switch_type == "clean" else False + self._state = ( + self._tc_object.is_cleaning if description.key == "clean" else False + ) self.lock = False self.last_lock_time = None self.graceful_state = False + self._attr_name = f"{tc_object} {description.name}" + def lock_update(self): """Lock the update since TC clean takes some time to update.""" if self.is_update_locked(): @@ -92,15 +111,10 @@ class ThinkingCleanerSwitch(ToggleEntity): return True - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._tc_object.name} {SWITCH_TYPES[self.type][0]}" - @property def is_on(self): """Return true if device is on.""" - if self.type == "clean": + if self.entity_description.key == "clean": return ( self.graceful_state if self.is_update_locked() @@ -111,22 +125,23 @@ class ThinkingCleanerSwitch(ToggleEntity): def turn_on(self, **kwargs): """Turn the device on.""" - if self.type == "clean": + sensor_type = self.entity_description.key + if sensor_type == "clean": self.set_graceful_lock(True) self._tc_object.start_cleaning() - elif self.type == "dock": + elif sensor_type == "dock": self._tc_object.dock() - elif self.type == "find": + elif sensor_type == "find": self._tc_object.find_me() def turn_off(self, **kwargs): """Turn the device off.""" - if self.type == "clean": + if self.entity_description.key == "clean": self.set_graceful_lock(False) self._tc_object.stop_cleaning() def update(self): """Update the switch state (Only for clean).""" - if self.type == "clean" and not self.is_update_locked(): + if self.entity_description.key == "clean" and not self.is_update_locked(): self._tc_object.update() self._state = STATE_ON if self._tc_object.is_cleaning else STATE_OFF diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index f092e1d8f55..d376bf0a7d5 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -2,9 +2,7 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from datetime import timedelta -from enum import Enum import logging from random import randrange @@ -19,6 +17,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -26,17 +25,15 @@ from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, + EVENT_HOMEASSISTANT_STOP, PERCENTAGE, POWER_WATT, SIGNAL_STRENGTH_DECIBELS, ) from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import update_coordinator from homeassistant.helpers.device_registry import async_get as async_get_dev_reg -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg from homeassistant.util import Throttle, dt as dt_util @@ -51,173 +48,150 @@ PARALLEL_UPDATES = 0 SIGNAL_UPDATE_ENTITY = "tibber_rt_update_{}" -class ResetType(Enum): - """Data reset type.""" - - HOURLY = "hourly" - DAILY = "daily" - NEVER = "never" - - -@dataclass -class TibberSensorEntityDescription(SensorEntityDescription): - """Describes Tibber sensor entity.""" - - reset_type: ResetType | None = None - - -RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { - "averagePower": TibberSensorEntityDescription( +RT_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( key="averagePower", name="average power", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), - "power": TibberSensorEntityDescription( + SensorEntityDescription( key="power", name="power", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), - "powerProduction": TibberSensorEntityDescription( + SensorEntityDescription( key="powerProduction", name="power production", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), - "minPower": TibberSensorEntityDescription( + SensorEntityDescription( key="minPower", name="min power", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), - "maxPower": TibberSensorEntityDescription( + SensorEntityDescription( key="maxPower", name="max power", device_class=DEVICE_CLASS_POWER, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ), - "accumulatedConsumption": TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedConsumption", name="accumulated consumption", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.DAILY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - "accumulatedConsumptionLastHour": TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedConsumptionLastHour", name="accumulated consumption current hour", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.HOURLY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - "accumulatedProduction": TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedProduction", name="accumulated production", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.DAILY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - "accumulatedProductionLastHour": TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedProductionLastHour", name="accumulated production current hour", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.HOURLY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - "lastMeterConsumption": TibberSensorEntityDescription( + SensorEntityDescription( key="lastMeterConsumption", name="last meter consumption", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.NEVER, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - "lastMeterProduction": TibberSensorEntityDescription( + SensorEntityDescription( key="lastMeterProduction", name="last meter production", device_class=DEVICE_CLASS_ENERGY, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.NEVER, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, ), - "voltagePhase1": TibberSensorEntityDescription( + SensorEntityDescription( key="voltagePhase1", name="voltage phase1", device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - "voltagePhase2": TibberSensorEntityDescription( + SensorEntityDescription( key="voltagePhase2", name="voltage phase2", device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - "voltagePhase3": TibberSensorEntityDescription( + SensorEntityDescription( key="voltagePhase3", name="voltage phase3", device_class=DEVICE_CLASS_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=STATE_CLASS_MEASUREMENT, ), - "currentL1": TibberSensorEntityDescription( + SensorEntityDescription( key="currentL1", name="current L1", device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - "currentL2": TibberSensorEntityDescription( + SensorEntityDescription( key="currentL2", name="current L2", device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - "currentL3": TibberSensorEntityDescription( + SensorEntityDescription( key="currentL3", name="current L3", device_class=DEVICE_CLASS_CURRENT, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state_class=STATE_CLASS_MEASUREMENT, ), - "signalStrength": TibberSensorEntityDescription( + SensorEntityDescription( key="signalStrength", name="signal strength", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=STATE_CLASS_MEASUREMENT, ), - "accumulatedReward": TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedReward", name="accumulated reward", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.DAILY, ), - "accumulatedCost": TibberSensorEntityDescription( + SensorEntityDescription( key="accumulatedCost", name="accumulated cost", device_class=DEVICE_CLASS_MONETARY, state_class=STATE_CLASS_MEASUREMENT, - reset_type=ResetType.DAILY, ), - "powerFactor": TibberSensorEntityDescription( + SensorEntityDescription( key="powerFactor", name="power factor", device_class=DEVICE_CLASS_POWER_FACTOR, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=STATE_CLASS_MEASUREMENT, ), -} +) async def async_setup_entry(hass, entry, async_add_entities): @@ -243,7 +217,9 @@ async def async_setup_entry(hass, entry, async_add_entities): entities.append(TibberSensorElPrice(home)) if home.has_real_time_consumption: await home.rt_subscribe( - TibberRtDataHandler(async_add_entities, home, hass).async_callback + TibberRtDataCoordinator( + async_add_entities, home, hass + ).async_set_updated_data ) # migrate @@ -273,27 +249,23 @@ async def async_setup_entry(hass, entry, async_add_entities): class TibberSensor(SensorEntity): """Representation of a generic Tibber sensor.""" - def __init__(self, tibber_home): + def __init__(self, *args, tibber_home, **kwargs): """Initialize the sensor.""" + super().__init__(*args, **kwargs) self._tibber_home = tibber_home self._home_name = tibber_home.info["viewer"]["home"]["appNickname"] - self._device_name = None if self._home_name is None: self._home_name = tibber_home.info["viewer"]["home"]["address"].get( "address1", "" ) + self._device_name = None self._model = None - @property - def device_id(self): - """Return the ID of the physical device this sensor is part of.""" - return self._tibber_home.home_id - @property def device_info(self): """Return the device_info of the device.""" device_info = { - "identifiers": {(TIBBER_DOMAIN, self.device_id)}, + "identifiers": {(TIBBER_DOMAIN, self._tibber_home.home_id)}, "name": self._device_name, "manufacturer": MANUFACTURER, } @@ -307,7 +279,7 @@ class TibberSensorElPrice(TibberSensor): def __init__(self, tibber_home): """Initialize the sensor.""" - super().__init__(tibber_home) + super().__init__(tibber_home=tibber_home) self._last_updated = None self._spread_load_constant = randrange(5000) @@ -352,13 +324,13 @@ class TibberSensorElPrice(TibberSensor): return res = self._tibber_home.current_price_data() - self._attr_state, price_level, self._last_updated = res + self._attr_native_value, price_level, self._last_updated = res self._attr_extra_state_attributes["price_level"] = price_level attrs = self._tibber_home.current_attributes() self._attr_extra_state_attributes.update(attrs) - self._attr_available = self._attr_state is not None - self._attr_unit_of_measurement = self._tibber_home.price_unit + self._attr_available = self._attr_native_value is not None + self._attr_native_unit_of_measurement = self._tibber_home.price_unit @Throttle(MIN_TIME_BETWEEN_UPDATES) async def _fetch_data(self): @@ -377,52 +349,28 @@ class TibberSensorElPrice(TibberSensor): ]["estimatedAnnualConsumption"] -class TibberSensorRT(TibberSensor): +class TibberSensorRT(TibberSensor, update_coordinator.CoordinatorEntity): """Representation of a Tibber sensor for real time consumption.""" - _attr_should_poll = False - entity_description: TibberSensorEntityDescription - def __init__( self, tibber_home, - description: TibberSensorEntityDescription, + description: SensorEntityDescription, initial_state, + coordinator: TibberRtDataCoordinator, ): """Initialize the sensor.""" - super().__init__(tibber_home) + super().__init__(coordinator=coordinator, tibber_home=tibber_home) self.entity_description = description self._model = "Tibber Pulse" self._device_name = f"{self._model} {self._home_name}" self._attr_name = f"{description.name} {self._home_name}" - self._attr_state = initial_state + self._attr_native_value = initial_state self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.name}" - if description.name in ("accumulated cost", "accumulated reward"): - self._attr_unit_of_measurement = tibber_home.currency - if description.reset_type == ResetType.NEVER: - self._attr_last_reset = dt_util.utc_from_timestamp(0) - elif description.reset_type == ResetType.DAILY: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - ) - elif description.reset_type == ResetType.HOURLY: - self._attr_last_reset = dt_util.as_utc( - dt_util.now().replace(minute=0, second=0, microsecond=0) - ) - else: - self._attr_last_reset = None - - async def async_added_to_hass(self): - """Start listen for real time data.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_UPDATE_ENTITY.format(self.unique_id), - self._set_state, - ) - ) + if description.key in ("accumulatedCost", "accumulatedReward"): + self._attr_native_unit_of_measurement = tibber_home.currency @property def available(self): @@ -430,27 +378,19 @@ class TibberSensorRT(TibberSensor): return self._tibber_home.rt_subscription_running @callback - def _set_state(self, state, timestamp): - """Set sensor state.""" - if ( - state < self._attr_state - and self.entity_description.reset_type == ResetType.DAILY - ): - self._attr_last_reset = dt_util.as_utc( - timestamp.replace(hour=0, minute=0, second=0, microsecond=0) - ) - if ( - state < self._attr_state - and self.entity_description.reset_type == ResetType.HOURLY - ): - self._attr_last_reset = dt_util.as_utc( - timestamp.replace(minute=0, second=0, microsecond=0) - ) - self._attr_state = state + def _handle_coordinator_update(self) -> None: + if not (live_measurement := self.coordinator.get_live_measurement()): # type: ignore[attr-defined] + return + state = live_measurement.get(self.entity_description.key) + if state is None: + return + if self.entity_description.key == "powerFactor": + state *= 100.0 + self._attr_native_value = state self.async_write_ha_state() -class TibberRtDataHandler: +class TibberRtDataCoordinator(update_coordinator.DataUpdateCoordinator): """Handle Tibber realtime data.""" def __init__(self, async_add_entities, tibber_home, hass): @@ -458,42 +398,53 @@ class TibberRtDataHandler: self._async_add_entities = async_add_entities self._tibber_home = tibber_home self.hass = hass - self._entities = {} + self._added_sensors = set() + super().__init__( + hass, + _LOGGER, + name=tibber_home.info["viewer"]["home"]["address"].get( + "address1", "Tibber" + ), + ) - async def async_callback(self, payload): - """Handle received data.""" - errors = payload.get("errors") - if errors: - _LOGGER.error(errors[0]) - return - data = payload.get("data") - if data is None: - return - live_measurement = data.get("liveMeasurement") - if live_measurement is None: + self._async_remove_device_updates_handler = self.async_add_listener( + self._add_sensors + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + + @callback + def _handle_ha_stop(self, _event) -> None: + """Handle Home Assistant stopping.""" + self._async_remove_device_updates_handler() + + @callback + def _add_sensors(self): + """Add sensor.""" + if not (live_measurement := self.get_live_measurement()): return - timestamp = dt_util.parse_datetime(live_measurement.pop("timestamp")) new_entities = [] - for sensor_type, state in live_measurement.items(): - if state is None or sensor_type not in RT_SENSOR_MAP: + for sensor_description in RT_SENSORS: + if sensor_description.key in self._added_sensors: continue - if sensor_type == "powerFactor": - state *= 100.0 - if sensor_type in self._entities: - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_ENTITY.format(self._entities[sensor_type]), - state, - timestamp, - ) - else: - entity = TibberSensorRT( - self._tibber_home, - RT_SENSOR_MAP[sensor_type], - state, - ) - new_entities.append(entity) - self._entities[sensor_type] = entity.unique_id + state = live_measurement.get(sensor_description.key) + if state is None: + continue + entity = TibberSensorRT( + self._tibber_home, + sensor_description, + state, + self, + ) + new_entities.append(entity) + self._added_sensors.add(sensor_description.key) if new_entities: self._async_add_entities(new_entities) + + def get_live_measurement(self): + """Get live measurement data.""" + errors = self.data.get("errors") + if errors: + _LOGGER.error(errors[0]) + return None + return self.data.get("data", {}).get("liveMeasurement") diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 08195e6dd3d..58582b3b139 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -65,7 +65,7 @@ class TimeDateSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 2e92ddf6fd8..31b9b14c9da 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Callable import voluptuous as vol @@ -196,7 +197,7 @@ class Timer(RestoreEntity): self._duration = cv.time_period_str(config[CONF_DURATION]) self._remaining: timedelta | None = None self._end: datetime | None = None - self._listener = None + self._listener: Callable[[], None] | None = None @classmethod def from_yaml(cls, config: dict) -> Timer: diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 88471a86c27..d777fec38b6 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -85,7 +85,7 @@ class TMBSensor(SensorEntity): return ICON @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit @@ -95,7 +95,7 @@ class TMBSensor(SensorEntity): return f"{self._stop}_{self._line}" @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 86aeff7c554..51f4e859a1f 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -1,4 +1,6 @@ """Support for Todoist task management (https://todoist.com).""" +from __future__ import annotations + from datetime import datetime, timedelta import logging @@ -226,14 +228,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) -def _parse_due_date(data: dict, gmt_string) -> datetime: +def _parse_due_date(data: dict, gmt_string) -> datetime | None: """Parse the due date dict into a datetime object.""" # Add time information to date only strings. if len(data["date"]) == 10: return datetime.fromisoformat(data["date"]).replace(tzinfo=dt.UTC) - if dt.parse_datetime(data["date"]).tzinfo is None: + nowtime = dt.parse_datetime(data["date"]) + if not nowtime: + return None + if nowtime.tzinfo is None: data["date"] += gmt_string - return dt.as_utc(dt.parse_datetime(data["date"])) + return dt.as_utc(nowtime) class TodoistProjectDevice(CalendarEventDevice): @@ -533,6 +538,8 @@ class TodoistProjectData: due_date = _parse_due_date( task["due"], self._api.state["user"]["tz_info"]["gmt_string"] ) + if not due_date: + continue midnight = dt.as_utc( dt.parse_datetime( due_date.strftime("%Y-%m-%d") diff --git a/homeassistant/components/todoist/services.yaml b/homeassistant/components/todoist/services.yaml index 85e975e94ff..d0b680375f9 100644 --- a/homeassistant/components/todoist/services.yaml +++ b/homeassistant/components/todoist/services.yaml @@ -30,7 +30,7 @@ new_task: min: 1 max: 4 due_date_string: - name: Dure date string + name: Due date string description: The day this task is due, in natural language. example: Tomorrow selector: diff --git a/homeassistant/components/tof/sensor.py b/homeassistant/components/tof/sensor.py index 45713dd8f77..631018f55cd 100644 --- a/homeassistant/components/tof/sensor.py +++ b/homeassistant/components/tof/sensor.py @@ -82,12 +82,12 @@ class VL53L1XSensor(SensorEntity): return self._name @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 4af57e03412..678b3400b88 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -6,24 +6,25 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PROBLEM, ) from homeassistant.components.sensor import ( - ATTR_LAST_RESET, 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, ) -from homeassistant.util import dt as dt_util DOMAIN = "toon" @@ -38,7 +39,6 @@ DEFAULT_MIN_TEMP = 6.0 CURRENCY_EUR = "EUR" VOLUME_CM3 = "CM3" -VOLUME_M3 = "M3" VOLUME_LHOUR = "L/H" VOLUME_LMIN = "L/MIN" @@ -125,16 +125,16 @@ SENSOR_ENTITIES = { ATTR_NAME: "Average Daily Gas Usage", ATTR_SECTION: "gas_usage", ATTR_MEASUREMENT: "day_average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, - ATTR_ICON: "mdi:gas-cylinder", + 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_UNIT_OF_MEASUREMENT: VOLUME_M3, - ATTR_ICON: "mdi:gas-cylinder", + ATTR_DEVICE_CLASS: DEVICE_CLASS_GAS, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, }, "gas_daily_cost": { ATTR_NAME: "Gas Cost Today", @@ -147,10 +147,9 @@ SENSOR_ENTITIES = { ATTR_NAME: "Gas Meter", ATTR_SECTION: "gas_usage", ATTR_MEASUREMENT: "meter", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, - ATTR_ICON: "mdi:gas-cylinder", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + 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": { @@ -196,8 +195,7 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter_high", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEFAULT_ENABLED: False, }, "power_meter_reading_low": { @@ -206,8 +204,7 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter_low", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEFAULT_ENABLED: False, }, "power_value": { @@ -224,8 +221,7 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter_produced_high", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEFAULT_ENABLED: False, }, "solar_meter_reading_low_produced": { @@ -234,8 +230,7 @@ SENSOR_ENTITIES = { ATTR_MEASUREMENT: "meter_produced_low", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, ATTR_DEFAULT_ENABLED: False, }, "solar_value": { @@ -321,7 +316,7 @@ SENSOR_ENTITIES = { ATTR_NAME: "Average Daily Water Usage", ATTR_SECTION: "water_usage", ATTR_MEASUREMENT: "day_average", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:water", ATTR_DEFAULT_ENABLED: False, }, @@ -329,7 +324,7 @@ SENSOR_ENTITIES = { ATTR_NAME: "Water Usage Today", ATTR_SECTION: "water_usage", ATTR_MEASUREMENT: "day_usage", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:water", ATTR_DEFAULT_ENABLED: False, }, @@ -337,11 +332,10 @@ SENSOR_ENTITIES = { ATTR_NAME: "Water Meter", ATTR_SECTION: "water_usage", ATTR_MEASUREMENT: "meter", - ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, ATTR_ICON: "mdi:water", ATTR_DEFAULT_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_LAST_RESET: dt_util.utc_from_timestamp(0), + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, "water_value": { ATTR_NAME: "Current Water Usage", diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index b16672674af..4522e34943c 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -1,11 +1,7 @@ """Support for Toon sensors.""" from __future__ import annotations -from homeassistant.components.sensor import ( - ATTR_LAST_RESET, - ATTR_STATE_CLASS, - SensorEntity, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -127,10 +123,9 @@ class ToonSensor(ToonEntity, SensorEntity): ATTR_DEFAULT_ENABLED, True ) self._attr_icon = sensor.get(ATTR_ICON) - self._attr_last_reset = sensor.get(ATTR_LAST_RESET) self._attr_name = sensor[ATTR_NAME] self._attr_state_class = sensor.get(ATTR_STATE_CLASS) - self._attr_unit_of_measurement = sensor[ATTR_UNIT_OF_MEASUREMENT] + 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. @@ -139,7 +134,7 @@ class ToonSensor(ToonEntity, SensorEntity): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" section = getattr( self.coordinator.data, SENSOR_ENTITIES[self.key][ATTR_SECTION] diff --git a/homeassistant/components/toon/translations/en_GB.json b/homeassistant/components/toon/translations/en_GB.json new file mode 100644 index 00000000000..f348959d089 --- /dev/null +++ b/homeassistant/components/toon/translations/en_GB.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL.", + "unknown_authorize_url_generation": "Unknown error generating an authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/translations/hu.json b/homeassistant/components/toon/translations/hu.json index 6371bf4c6fd..18f333dccdf 100644 --- a/homeassistant/components/toon/translations/hu.json +++ b/homeassistant/components/toon/translations/hu.json @@ -1,11 +1,24 @@ { "config": { "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.", "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." + }, + "step": { + "agreement": { + "data": { + "agreement": "Meg\u00e1llapod\u00e1s" + }, + "description": "V\u00e1lassza ki a hozz\u00e1adni k\u00edv\u00e1nt szerz\u0151d\u00e9sc\u00edmet.", + "title": "V\u00e1lassza ki a meg\u00e1llapod\u00e1st" + }, + "pick_implementation": { + "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9shez" + } } } } \ No newline at end of file diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 8e3053d9bd8..162dd5f437c 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -120,12 +120,12 @@ class TorqueSensor(SensorEntity): return self._name @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/totalconnect/translations/hu.json b/homeassistant/components/totalconnect/translations/hu.json index e9e991d81d4..319611fd2b1 100644 --- a/homeassistant/components/totalconnect/translations/hu.json +++ b/homeassistant/components/totalconnect/translations/hu.json @@ -25,7 +25,8 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "Total Connect" } } } diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 64637b9cdb5..aad934b2600 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,7 +1,7 @@ """Component to embed TP-Link smart home devices.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import timedelta import logging import time from typing import Any @@ -11,7 +11,6 @@ from pyHS100.smartplug import SmartPlug import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.sensor import ATTR_LAST_RESET from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,14 +27,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.dt import as_local, utc_from_timestamp -from .common import ( - SmartDevices, - async_discover_devices, - get_static_devices, - get_time_offset, -) +from .common import SmartDevices, async_discover_devices, get_static_devices from .const import ( ATTR_CONFIG, ATTR_CURRENT_A, @@ -267,19 +260,8 @@ class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): ATTR_TOTAL_ENERGY_KWH: round(float(emeter_readings["total"]), 3), ATTR_VOLTAGE: round(float(emeter_readings["voltage"]), 1), ATTR_CURRENT_A: round(float(emeter_readings["current"]), 2), - ATTR_LAST_RESET: {ATTR_TOTAL_ENERGY_KWH: utc_from_timestamp(0)}, } emeter_statics = self.smartplug.get_emeter_daily() - last_reset = datetime.now() - get_time_offset(self.smartplug) - last_reset_local = as_local(last_reset.replace(second=0, microsecond=0)) - _LOGGER.debug( - "%s last reset time as local to server is %s", - self.smartplug.alias, - last_reset_local.strftime("%Y/%m/%d %H:%M:%S"), - ) - data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ - ATTR_TODAY_ENERGY_KWH - ] = last_reset_local 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 diff --git a/homeassistant/components/tplink/common.py b/homeassistant/components/tplink/common.py index 8acd6f29cba..6f6fb0a14c2 100644 --- a/homeassistant/components/tplink/common.py +++ b/homeassistant/components/tplink/common.py @@ -1,7 +1,6 @@ """Common code for tplink.""" from __future__ import annotations -from datetime import timedelta import logging from typing import Callable @@ -185,16 +184,3 @@ def add_available_devices( hass.data[TPLINK_DOMAIN][f"{device_type}_remaining"] = devices_unavailable return entities_ready - - -def get_time_offset(device: SmartDevice) -> timedelta: - """Get the time offset since last device reset (local midnight).""" - device_time = device.time.replace(microsecond=0) - offset = device_time - device_time.replace(hour=0, minute=0, second=0) - _LOGGER.debug( - "%s local time is %s, offset from midnight is %s", - device.alias, - device_time.strftime("%Y/%m/%d %H:%M:%S"), - str(offset), - ) - return offset diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 3d7f26bb786..4d2ed5eee30 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -1,14 +1,13 @@ """Support for TPLink HS100/HS110/HS200 smart switch energy sensors.""" from __future__ import annotations -from datetime import datetime from typing import Any, Final from pyHS100 import SmartPlug from homeassistant.components.sensor import ( - ATTR_LAST_RESET, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -54,35 +53,35 @@ ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" ENERGY_SENSORS: Final[list[SensorEntityDescription]] = [ SensorEntityDescription( key=ATTR_CURRENT_POWER_W, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, name="Current Consumption", ), SensorEntityDescription( key=ATTR_TOTAL_ENERGY_KWH, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, name="Total Consumption", ), SensorEntityDescription( key=ATTR_TODAY_ENERGY_KWH, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, name="Today's Consumption", ), SensorEntityDescription( key=ATTR_VOLTAGE, - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, name="Voltage", ), SensorEntityDescription( key=ATTR_CURRENT_A, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, name="Current", @@ -128,9 +127,6 @@ class SmartPlugSensor(CoordinatorEntity, SensorEntity): self.smartplug = smartplug self.entity_description = description self._attr_name = f"{coordinator.data[CONF_ALIAS]} {description.name}" - self._attr_last_reset = coordinator.data[CONF_EMETER_PARAMS][ - ATTR_LAST_RESET - ].get(description.key) @property def data(self) -> dict[str, Any]: @@ -138,7 +134,7 @@ class SmartPlugSensor(CoordinatorEntity, SensorEntity): return self.coordinator.data @property - def state(self) -> float | None: + def native_value(self) -> float | None: """Return the sensors state.""" return self.data[CONF_EMETER_PARAMS][self.entity_description.key] @@ -157,10 +153,3 @@ class SmartPlugSensor(CoordinatorEntity, SensorEntity): "connections": {(dr.CONNECTION_NETWORK_MAC, self.data[CONF_MAC])}, "sw_version": self.data[CONF_SW_VERSION], } - - @property - def last_reset(self) -> datetime | None: - """Return the last reset time for emeter.""" - return self.data[CONF_EMETER_PARAMS][ATTR_LAST_RESET].get( - self.entity_description.key - ) diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 5ad5879f31b..16cd9ba94e5 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -74,6 +74,26 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL +EVENTS = [ + EVENT_DEVICE_MOVING, + EVENT_COMMAND_RESULT, + EVENT_DEVICE_FUEL_DROP, + EVENT_GEOFENCE_ENTER, + EVENT_DEVICE_OFFLINE, + EVENT_DRIVER_CHANGED, + EVENT_GEOFENCE_EXIT, + EVENT_DEVICE_OVERSPEED, + EVENT_DEVICE_ONLINE, + EVENT_DEVICE_STOPPED, + EVENT_MAINTENANCE, + EVENT_ALARM, + EVENT_TEXT_MESSAGE, + EVENT_DEVICE_UNKNOWN, + EVENT_IGNITION_OFF, + EVENT_IGNITION_ON, + EVENT_ALL_EVENTS, +] + PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_PASSWORD): cv.string, @@ -91,27 +111,7 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ), vol.Optional(CONF_EVENT, default=[]): vol.All( cv.ensure_list, - [ - vol.Any( - EVENT_DEVICE_MOVING, - EVENT_COMMAND_RESULT, - EVENT_DEVICE_FUEL_DROP, - EVENT_GEOFENCE_ENTER, - EVENT_DEVICE_OFFLINE, - EVENT_DRIVER_CHANGED, - EVENT_GEOFENCE_EXIT, - EVENT_DEVICE_OVERSPEED, - EVENT_DEVICE_ONLINE, - EVENT_DEVICE_STOPPED, - EVENT_MAINTENANCE, - EVENT_ALARM, - EVENT_TEXT_MESSAGE, - EVENT_DEVICE_UNKNOWN, - EVENT_IGNITION_OFF, - EVENT_IGNITION_ON, - EVENT_ALL_EVENTS, - ) - ], + [vol.In(EVENTS)], ), } ) @@ -203,6 +203,8 @@ class TraccarScanner: ): """Initialize.""" + if EVENT_ALL_EVENTS in event_types: + event_types = EVENTS self._event_types = {camelcase(evt): evt for evt in event_types} self._custom_attributes = custom_attributes self._scan_interval = scan_interval diff --git a/homeassistant/components/traccar/translations/hu.json b/homeassistant/components/traccar/translations/hu.json index c4fc027d059..94fc9198921 100644 --- a/homeassistant/components/traccar/translations/hu.json +++ b/homeassistant/components/traccar/translations/hu.json @@ -6,6 +6,12 @@ }, "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})." + }, + "step": { + "user": { + "description": "Biztosan be\u00e1ll\u00edtja a Traccar szolg\u00e1ltat\u00e1st?", + "title": "A Traccar be\u00e1ll\u00edt\u00e1sa" + } } } } \ No newline at end of file diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py new file mode 100644 index 00000000000..60014852895 --- /dev/null +++ b/homeassistant/components/tractive/__init__.py @@ -0,0 +1,174 @@ +"""The tractive integration.""" +from __future__ import annotations + +import asyncio +import logging + +import aiotractive + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_EMAIL, + CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + ATTR_DAILY_GOAL, + ATTR_MINUTES_ACTIVE, + DOMAIN, + RECONNECT_INTERVAL, + SERVER_UNAVAILABLE, + TRACKER_ACTIVITY_STATUS_UPDATED, + TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_POSITION_UPDATED, +) + +PLATFORMS = ["device_tracker", "sensor"] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tractive from a config entry.""" + data = entry.data + + hass.data.setdefault(DOMAIN, {}) + + client = aiotractive.Tractive( + data[CONF_EMAIL], data[CONF_PASSWORD], session=async_get_clientsession(hass) + ) + try: + creds = await client.authenticate() + except aiotractive.exceptions.UnauthorizedError as error: + await client.close() + raise ConfigEntryAuthFailed from error + except aiotractive.exceptions.TractiveError as error: + await client.close() + raise ConfigEntryNotReady from error + + tractive = TractiveClient(hass, client, creds["user_id"]) + tractive.subscribe() + + hass.data[DOMAIN][entry.entry_id] = tractive + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + async def cancel_listen_task(_): + await tractive.unsubscribe() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cancel_listen_task) + ) + + 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: + tractive = hass.data[DOMAIN].pop(entry.entry_id) + await tractive.unsubscribe() + return unload_ok + + +class TractiveClient: + """A Tractive client.""" + + def __init__(self, hass, client, user_id): + """Initialize the client.""" + self._hass = hass + self._client = client + self._user_id = user_id + self._listen_task = None + + @property + def user_id(self): + """Return user id.""" + return self._user_id + + async def trackable_objects(self): + """Get list of trackable objects.""" + return await self._client.trackable_objects() + + def tracker(self, tracker_id): + """Get tracker by id.""" + return self._client.tracker(tracker_id) + + def subscribe(self): + """Start event listener coroutine.""" + self._listen_task = asyncio.create_task(self._listen()) + + async def unsubscribe(self): + """Stop event listener coroutine.""" + if self._listen_task: + self._listen_task.cancel() + await self._client.close() + + async def _listen(self): + server_was_unavailable = False + while True: + try: + async for event in self._client.events(): + if server_was_unavailable: + _LOGGER.debug("Tractive is back online") + server_was_unavailable = False + + if event["message"] == "activity_update": + self._send_activity_update(event) + else: + if "hardware" in event: + self._send_hardware_update(event) + + if "position" in event: + self._send_position_update(event) + except aiotractive.exceptions.TractiveError: + _LOGGER.debug( + "Tractive is not available. Internet connection is down? Sleeping %i seconds and retrying", + RECONNECT_INTERVAL.total_seconds(), + ) + async_dispatcher_send( + self._hass, f"{SERVER_UNAVAILABLE}-{self._user_id}" + ) + await asyncio.sleep(RECONNECT_INTERVAL.total_seconds()) + server_was_unavailable = True + continue + + def _send_hardware_update(self, event): + payload = {ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"]} + self._dispatch_tracker_event( + TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload + ) + + def _send_activity_update(self, event): + payload = { + ATTR_MINUTES_ACTIVE: event["progress"]["achieved_minutes"], + ATTR_DAILY_GOAL: event["progress"]["goal_minutes"], + } + self._dispatch_tracker_event( + TRACKER_ACTIVITY_STATUS_UPDATED, event["pet_id"], payload + ) + + def _send_position_update(self, event): + payload = { + "latitude": event["position"]["latlong"][0], + "longitude": event["position"]["latlong"][1], + "accuracy": event["position"]["accuracy"], + } + self._dispatch_tracker_event( + TRACKER_POSITION_UPDATED, event["tracker_id"], payload + ) + + def _dispatch_tracker_event(self, event_name, tracker_id, payload): + async_dispatcher_send( + self._hass, + f"{event_name}-{tracker_id}", + payload, + ) diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py new file mode 100644 index 00000000000..4b1fc241110 --- /dev/null +++ b/homeassistant/components/tractive/config_flow.py @@ -0,0 +1,106 @@ +"""Config flow for tractive integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiotractive +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_EMAIL): 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. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + client = aiotractive.api.API(data[CONF_EMAIL], data[CONF_PASSWORD]) + try: + user_id = await client.user_id() + except aiotractive.exceptions.UnauthorizedError as error: + raise InvalidAuth from error + finally: + await client.close() + + return {"title": data[CONF_EMAIL], "user_id": user_id} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for tractive.""" + + 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=USER_DATA_SCHEMA) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + 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["user_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=USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, _: dict[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + + errors = {} + + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + existing_entry = await self.async_set_unique_id(info["user_id"]) + if existing_entry: + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_failed_existing") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=USER_DATA_SCHEMA, + errors=errors, + ) + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py new file mode 100644 index 00000000000..cb525d538e4 --- /dev/null +++ b/homeassistant/components/tractive/const.py @@ -0,0 +1,16 @@ +"""Constants for the tractive integration.""" + +from datetime import timedelta + +DOMAIN = "tractive" + +RECONNECT_INTERVAL = timedelta(seconds=10) + +ATTR_DAILY_GOAL = "daily_goal" +ATTR_MINUTES_ACTIVE = "minutes_active" + +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" + +SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py new file mode 100644 index 00000000000..c1652c27b8f --- /dev/null +++ b/homeassistant/components/tractive/device_tracker.py @@ -0,0 +1,139 @@ +"""Support for Tractive device trackers.""" + +import asyncio +import logging + +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + DOMAIN, + SERVER_UNAVAILABLE, + TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_POSITION_UPDATED, +) +from .entity import TractiveEntity + +_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] + + trackables = await client.trackable_objects() + + entities = await asyncio.gather( + *(create_trackable_entity(client, trackable) for trackable in trackables) + ) + + 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.""" + + _attr_icon = "mdi:paw" + + def __init__(self, user_id, trackable, tracker_details, hw_info, pos_report): + """Initialize tracker entity.""" + super().__init__(user_id, trackable, tracker_details) + + self._battery_level = hw_info["battery_level"] + self._latitude = pos_report["latlong"][0] + self._longitude = pos_report["latlong"][1] + self._accuracy = pos_report["pos_uncertainty"] + + self._attr_name = f"{self._tracker_id} {trackable['details']['name']}" + self._attr_unique_id = trackable["_id"] + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._latitude + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._longitude + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._accuracy + + @property + def battery_level(self): + """Return the battery level of the device.""" + return self._battery_level + + @callback + def _handle_hardware_status_update(self, event): + self._battery_level = event["battery_level"] + self._attr_available = True + self.async_write_ha_state() + + @callback + def _handle_position_update(self, event): + self._latitude = event["latitude"] + self._longitude = event["longitude"] + self._accuracy = event["accuracy"] + self._attr_available = True + self.async_write_ha_state() + + @callback + def _handle_server_unavailable(self): + self._latitude = None + self._longitude = None + self._accuracy = None + self._battery_level = None + self._attr_available = False + 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"{TRACKER_POSITION_UPDATED}-{self._tracker_id}", + self._handle_position_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self._handle_server_unavailable, + ) + ) diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py new file mode 100644 index 00000000000..4ddc7f7aa35 --- /dev/null +++ b/homeassistant/components/tractive/entity.py @@ -0,0 +1,22 @@ +"""A entity class for Tractive integration.""" + +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class TractiveEntity(Entity): + """Tractive entity class.""" + + def __init__(self, user_id, trackable, tracker_details): + """Initialize tracker entity.""" + self._attr_device_info = { + "identifiers": {(DOMAIN, tracker_details["_id"])}, + "name": f"Tractive ({tracker_details['_id']})", + "manufacturer": "Tractive GmbH", + "sw_version": tracker_details["fw_version"], + "model": tracker_details["model_number"], + } + self._user_id = user_id + self._tracker_id = tracker_details["_id"] + self._trackable = trackable diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json new file mode 100644 index 00000000000..b388703e6bd --- /dev/null +++ b/homeassistant/components/tractive/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "tractive", + "name": "Tractive", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tractive", + "requirements": [ + "aiotractive==0.5.2" + ], + "codeowners": [ + "@Danielhiversen", + "@zhulik", + "@bieniu" + ], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py new file mode 100644 index 00000000000..ba2f330f894 --- /dev/null +++ b/homeassistant/components/tractive/sensor.py @@ -0,0 +1,165 @@ +"""Support for Tractive sensors.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + DEVICE_CLASS_BATTERY, + PERCENTAGE, + TIME_MINUTES, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + ATTR_DAILY_GOAL, + ATTR_MINUTES_ACTIVE, + DOMAIN, + SERVER_UNAVAILABLE, + TRACKER_ACTIVITY_STATUS_UPDATED, + TRACKER_HARDWARE_STATUS_UPDATED, +) +from .entity import TractiveEntity + + +@dataclass +class TractiveSensorEntityDescription(SensorEntityDescription): + """Class describing Tractive sensor entities.""" + + entity_class: type[TractiveSensor] | None = None + + +class TractiveSensor(TractiveEntity, SensorEntity): + """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() + + +class TractiveHardwareSensor(TractiveSensor): + """Tractive hardware sensor.""" + + @callback + def handle_hardware_status_update(self, event): + """Handle hardware status update.""" + self._attr_native_value = 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, + ) + ) + + +class TractiveActivitySensor(TractiveSensor): + """Tractive active sensor.""" + + @callback + def handle_activity_status_update(self, event): + """Handle activity status update.""" + self._attr_native_value = 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_ACTIVITY_STATUS_UPDATED}-{self._trackable['_id']}", + self.handle_activity_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + +SENSOR_TYPES = ( + TractiveSensorEntityDescription( + key=ATTR_BATTERY_LEVEL, + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + entity_class=TractiveHardwareSensor, + ), + TractiveSensorEntityDescription( + key=ATTR_MINUTES_ACTIVE, + name="Minutes Active", + icon="mdi:clock-time-eight-outline", + native_unit_of_measurement=TIME_MINUTES, + entity_class=TractiveActivitySensor, + ), + TractiveSensorEntityDescription( + key=ATTR_DAILY_GOAL, + name="Daily Goal", + icon="mdi:flag-checkered", + native_unit_of_measurement=TIME_MINUTES, + entity_class=TractiveActivitySensor, + ), +) + + +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() + + 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 description in SENSOR_TYPES: + unique_id = f"{trackable['_id']}_{description.key}" + entities.append( + description.entity_class( + client.user_id, + trackable, + tracker_details, + unique_id, + description, + ) + ) + + await asyncio.gather(*(_prepare_sensor_entity(item) for item in trackables)) + + async_add_entities(entities) diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json new file mode 100644 index 00000000000..9711eb41489 --- /dev/null +++ b/homeassistant/components/tractive/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/ca.json b/homeassistant/components/tractive/translations/ca.json new file mode 100644 index 00000000000..0641dd2737b --- /dev/null +++ b/homeassistant/components/tractive/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu 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" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/cs.json b/homeassistant/components/tractive/translations/cs.json new file mode 100644 index 00000000000..3ad489e1f5e --- /dev/null +++ b/homeassistant/components/tractive/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/de.json b/homeassistant/components/tractive/translations/de.json new file mode 100644 index 00000000000..cad80fd36a8 --- /dev/null +++ b/homeassistant/components/tractive/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_failed_existing": "Der Konfigurationseintrag konnte nicht aktualisiert werden. Bitte entferne die Integration und richte sie erneut ein.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/en.json b/homeassistant/components/tractive/translations/en.json new file mode 100644 index 00000000000..dcb3a128ac4 --- /dev/null +++ b/homeassistant/components/tractive/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/es.json b/homeassistant/components/tractive/translations/es.json new file mode 100644 index 00000000000..11aa4f1aa9c --- /dev/null +++ b/homeassistant/components/tractive/translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El sistema ya est\u00e1 configurado" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n err\u00f3nea", + "unknown": "Error desconocido" + }, + "step": { + "user": { + "data": { + "email": "Correo-e", + "password": "Clave" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/et.json b/homeassistant/components/tractive/translations/et.json new file mode 100644 index 00000000000..67adf622ebe --- /dev/null +++ b/homeassistant/components/tractive/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_failed_existing": "Seadekirjet ei \u00f5nnestunud uuendada, eemalda sidumine ja seadista see uuesti.", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/fr.json b/homeassistant/components/tractive/translations/fr.json new file mode 100644 index 00000000000..1d3c15c13d5 --- /dev/null +++ b/homeassistant/components/tractive/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositif d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "email": "Adresse mail", + "password": "Mot de passe" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/he.json b/homeassistant/components/tractive/translations/he.json new file mode 100644 index 00000000000..10f72473084 --- /dev/null +++ b/homeassistant/components/tractive/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "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", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/hu.json b/homeassistant/components/tractive/translations/hu.json new file mode 100644 index 00000000000..d0f75a28ed0 --- /dev/null +++ b/homeassistant/components/tractive/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_failed_existing": "Nem siker\u00fclt friss\u00edteni a konfigur\u00e1ci\u00f3s bejegyz\u00e9st. K\u00e9rj\u00fck, t\u00e1vol\u00edtsa el az integr\u00e1ci\u00f3t, \u00e9s \u00e1ll\u00edtsa be \u00fajra.", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "Ismeretlen hiba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Jelsz\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/it.json b/homeassistant/components/tractive/translations/it.json new file mode 100644 index 00000000000..44cdc2df3d7 --- /dev/null +++ b/homeassistant/components/tractive/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_failed_existing": "Impossibile aggiornare la voce di configurazione, rimuovere l'integrazione e configurarla di nuovo.", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/nl.json b/homeassistant/components/tractive/translations/nl.json new file mode 100644 index 00000000000..b0e1f17cdc3 --- /dev/null +++ b/homeassistant/components/tractive/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "reauth_failed_existing": "Kon het configuratie-item niet bijwerken, verwijder de integratie en stel deze opnieuw in.", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/no.json b/homeassistant/components/tractive/translations/no.json new file mode 100644 index 00000000000..a768b453848 --- /dev/null +++ b/homeassistant/components/tractive/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "reauth_failed_existing": "Kunne ikke oppdatere konfigurasjonsoppf\u00f8ringen. Fjern integrasjonen og sett den opp igjen.", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/pl.json b/homeassistant/components/tractive/translations/pl.json new file mode 100644 index 00000000000..99379115ef1 --- /dev/null +++ b/homeassistant/components/tractive/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_failed_existing": "Nie mo\u017cna zaktualizowa\u0107 wpisu konfiguracji, usu\u0144 integracj\u0119 i skonfiguruj j\u0105 ponownie.", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/pt.json b/homeassistant/components/tractive/translations/pt.json new file mode 100644 index 00000000000..7430480cc09 --- /dev/null +++ b/homeassistant/components/tractive/translations/pt.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Palavra-passe" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/ru.json b/homeassistant/components/tractive/translations/ru.json new file mode 100644 index 00000000000..89042b79b5e --- /dev/null +++ b/homeassistant/components/tractive/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "reauth_failed_existing": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u0443\u0434\u0430\u043b\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0435\u0451 \u0441\u043d\u043e\u0432\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." + }, + "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." + }, + "step": { + "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" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/zh-Hans.json b/homeassistant/components/tractive/translations/zh-Hans.json new file mode 100644 index 00000000000..5d8e6c66984 --- /dev/null +++ b/homeassistant/components/tractive/translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u5b58\u5728\u914d\u7f6e\u6587\u6863" + }, + "error": { + "invalid_auth": "\u8ba4\u8bc1\u65e0\u6548", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "user": { + "data": { + "email": "\u7535\u5b50\u90ae\u7bb1", + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/zh-Hant.json b/homeassistant/components/tractive/translations/zh-Hant.json new file mode 100644 index 00000000000..8c9ec055f63 --- /dev/null +++ b/homeassistant/components/tractive/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_failed_existing": "\u7121\u6cd5\u66f4\u65b0\u8a2d\u5b9a\u5be6\u9ad4\uff0c\u8acb\u79fb\u9664\u6574\u5408\u4e26\u91cd\u65b0\u8a2d\u5b9a\u3002", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index cf39d3d6c05..2c113b63727 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -1,6 +1,9 @@ """Support for IKEA Tradfri.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory @@ -14,7 +17,6 @@ from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import load_json from .const import ( ATTR_TRADFRI_GATEWAY, @@ -26,7 +28,6 @@ from .const import ( CONF_IDENTITY, CONF_IMPORT_GROUPS, CONF_KEY, - CONFIG_FILE, DEFAULT_ALLOW_TRADFRI_GROUPS, DEVICES, DOMAIN, @@ -55,7 +56,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Tradfri component.""" conf = config.get(DOMAIN) @@ -66,27 +67,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): entry.data.get("host") for entry in hass.config_entries.async_entries(DOMAIN) ] - legacy_hosts = await hass.async_add_executor_job( - load_json, hass.config.path(CONFIG_FILE) - ) - - for host, info in legacy_hosts.items(): - if host in configured_hosts: - continue - - info[CONF_HOST] = host - info[CONF_IMPORT_GROUPS] = conf[CONF_ALLOW_TRADFRI_GROUPS] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=info - ) - ) - host = conf.get(CONF_HOST) import_groups = conf[CONF_ALLOW_TRADFRI_GROUPS] - if host is None or host in configured_hosts or host in legacy_hosts: + if host is None or host in configured_hosts: return True hass.async_create_task( @@ -103,7 +87,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Create a gateway.""" # host, identity, key, allow_tradfri_groups - tradfri_data = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {} + tradfri_data: dict[str, Any] = {} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = tradfri_data listeners = tradfri_data[LISTENERS] = [] factory = await APIFactory.init( diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py index f7c2bf6cbe5..1f382548263 100644 --- a/homeassistant/components/tradfri/const.py +++ b/homeassistant/components/tradfri/const.py @@ -15,7 +15,6 @@ CONF_IDENTITY = "identity" CONF_IMPORT_GROUPS = "import_groups" CONF_GATEWAY_ID = "gateway_id" CONF_KEY = "key" -CONFIG_FILE = ".tradfri_psk.conf" DEFAULT_ALLOW_TRADFRI_GROUPS = False DOMAIN = "tradfri" KEY_API = "tradfri_api" diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index 3e13cdc015a..7ffad04074d 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -7,6 +7,6 @@ "homekit": { "models": ["TRADFRI"] }, - "codeowners": [], + "codeowners": ["@janiversen"], "iot_class": "local_polling" } diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 1f028849d32..f7f68b666ba 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -30,7 +30,7 @@ class TradfriSensor(TradfriBaseDevice, SensorEntity): """The platform class required by Home Assistant.""" _attr_device_class = DEVICE_CLASS_BATTERY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device, api, gateway_id): """Initialize the device.""" @@ -38,6 +38,6 @@ class TradfriSensor(TradfriBaseDevice, SensorEntity): self._unique_id = f"{gateway_id}-{device.id}" @property - def state(self): + def native_value(self): """Return the current state of the device.""" return self._device.device_info.battery_level diff --git a/homeassistant/components/tradfri/translations/lt.json b/homeassistant/components/tradfri/translations/lt.json new file mode 100644 index 00000000000..2dff6a15f18 --- /dev/null +++ b/homeassistant/components/tradfri/translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "auth": { + "data": { + "security_code": "Saugumo kodas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 5e541045266..cd5cdf29521 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -189,7 +189,7 @@ class TrainSensor(SensorEntity): return ICON @property - def state(self): + def native_value(self): """Return the departure state.""" state = self._state if state is not None: diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 1ae090ea231..5fe3c462a56 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1,6 +1,8 @@ """Weather information for air and road temperature (by Trafikverket).""" +from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta import logging @@ -8,7 +10,11 @@ import aiohttp from pytrafikverket.trafikverket_weather import TrafikverketWeather 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_API_KEY, @@ -38,85 +44,102 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) SCAN_INTERVAL = timedelta(seconds=300) -SENSOR_TYPES = { - "air_temp": [ - "Air temperature", - TEMP_CELSIUS, - "air_temp", - "mdi:thermometer", - DEVICE_CLASS_TEMPERATURE, - ], - "road_temp": [ - "Road temperature", - TEMP_CELSIUS, - "road_temp", - "mdi:thermometer", - DEVICE_CLASS_TEMPERATURE, - ], - "precipitation": [ - "Precipitation type", - None, - "precipitationtype", - "mdi:weather-snowy-rainy", - None, - ], - "wind_direction": [ - "Wind direction", - DEGREE, - "winddirection", - "mdi:flag-triangle", - None, - ], - "wind_direction_text": [ - "Wind direction text", - None, - "winddirectiontext", - "mdi:flag-triangle", - None, - ], - "wind_speed": [ - "Wind speed", - SPEED_METERS_PER_SECOND, - "windforce", - "mdi:weather-windy", - None, - ], - "wind_speed_max": [ - "Wind speed max", - SPEED_METERS_PER_SECOND, - "windforcemax", - "mdi:weather-windy-variant", - None, - ], - "humidity": [ - "Humidity", - PERCENTAGE, - "humidity", - "mdi:water-percent", - DEVICE_CLASS_HUMIDITY, - ], - "precipitation_amount": [ - "Precipitation amount", - LENGTH_MILLIMETERS, - "precipitation_amount", - "mdi:cup-water", - None, - ], - "precipitation_amountname": [ - "Precipitation name", - None, - "precipitation_amountname", - "mdi:weather-pouring", - None, - ], -} + +@dataclass +class TrafikverketRequiredKeysMixin: + """Mixin for required keys.""" + + api_key: str + + +@dataclass +class TrafikverketSensorEntityDescription( + SensorEntityDescription, TrafikverketRequiredKeysMixin +): + """Describes Trafikverket sensor entity.""" + + +SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( + TrafikverketSensorEntityDescription( + key="air_temp", + api_key="air_temp", + name="Air temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + TrafikverketSensorEntityDescription( + key="road_temp", + api_key="road_temp", + name="Road temperature", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=DEVICE_CLASS_TEMPERATURE, + ), + TrafikverketSensorEntityDescription( + key="precipitation", + api_key="precipitationtype", + name="Precipitation type", + icon="mdi:weather-snowy-rainy", + ), + TrafikverketSensorEntityDescription( + key="wind_direction", + api_key="winddirection", + name="Wind direction", + native_unit_of_measurement=DEGREE, + icon="mdi:flag-triangle", + ), + TrafikverketSensorEntityDescription( + key="wind_direction_text", + api_key="winddirectiontext", + name="Wind direction text", + icon="mdi:flag-triangle", + ), + TrafikverketSensorEntityDescription( + key="wind_speed", + api_key="windforce", + name="Wind speed", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + icon="mdi:weather-windy", + ), + TrafikverketSensorEntityDescription( + key="wind_speed_max", + api_key="windforcemax", + name="Wind speed max", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + icon="mdi:weather-windy-variant", + ), + TrafikverketSensorEntityDescription( + key="humidity", + api_key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + device_class=DEVICE_CLASS_HUMIDITY, + ), + TrafikverketSensorEntityDescription( + key="precipitation_amount", + api_key="precipitation_amount", + name="Precipitation amount", + native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:cup-water", + ), + TrafikverketSensorEntityDescription( + key="precipitation_amountname", + api_key="precipitation_amountname", + name="Precipitation name", + icon="mdi:weather-pouring", + ), +) + +SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_STATION): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): [vol.In(SENSOR_TYPES)], + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): [vol.In(SENSOR_KEYS)], } ) @@ -132,44 +155,37 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= weather_api = TrafikverketWeather(web_session, sensor_api) - dev = [] - for condition in config[CONF_MONITORED_CONDITIONS]: - dev.append( - TrafikverketWeatherStation( - weather_api, sensor_name, condition, sensor_station - ) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + TrafikverketWeatherStation( + weather_api, sensor_name, sensor_station, description ) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] - if dev: - async_add_entities(dev, True) + async_add_entities(entities, True) class TrafikverketWeatherStation(SensorEntity): """Representation of a Trafikverket sensor.""" - def __init__(self, weather_api, name, sensor_type, sensor_station): + entity_description: TrafikverketSensorEntityDescription + + def __init__( + self, + weather_api, + name, + sensor_station, + description: TrafikverketSensorEntityDescription, + ): """Initialize the sensor.""" - self._client = name - self._name = SENSOR_TYPES[sensor_type][0] - self._type = sensor_type - self._state = None - self._unit = SENSOR_TYPES[sensor_type][1] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self._station = sensor_station self._weather_api = weather_api - self._icon = SENSOR_TYPES[sensor_type][3] - self._device_class = SENSOR_TYPES[sensor_type][4] self._weather = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._client} {self._name}" - - @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - @property def extra_state_attributes(self): """Return the state attributes of Trafikverket Weatherstation.""" @@ -179,26 +195,13 @@ class TrafikverketWeatherStation(SensorEntity): ATTR_MEASURE_TIME: self._weather.measure_time, } - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Get the latest data from Trafikverket and updates the states.""" try: self._weather = await self._weather_api.async_get_weather(self._station) - self._state = getattr(self._weather, SENSOR_TYPES[self._type][2]) + self._attr_native_value = getattr( + self._weather, self.entity_description.api_key + ) except (asyncio.TimeoutError, aiohttp.ClientError, ValueError) as error: _LOGGER.error("Could not fetch weather data: %s", error) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index e0ced70f15e..40edc8aeab9 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -447,6 +447,8 @@ class TransmissionData: def stop_torrents(self): """Stop all active torrents.""" + if len(self._torrents) == 0: + return torrent_ids = [torrent.id for torrent in self._torrents] self._api.stop_torrent(torrent_ids) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index b00ccfc68c0..e5f827d1e52 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -62,7 +62,7 @@ class TransmissionSensor(SensorEntity): return f"{self._tm_client.api.host}-{self.name}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -95,7 +95,7 @@ class TransmissionSpeedSensor(TransmissionSensor): """Representation of a Transmission speed sensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return DATA_RATE_MEGABYTES_PER_SECOND @@ -145,7 +145,7 @@ class TransmissionTorrentsSensor(TransmissionSensor): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return "Torrents" diff --git a/homeassistant/components/transmission/translations/hu.json b/homeassistant/components/transmission/translations/hu.json index 22d4e18df5e..5c968b21ed7 100644 --- a/homeassistant/components/transmission/translations/hu.json +++ b/homeassistant/components/transmission/translations/hu.json @@ -28,7 +28,8 @@ "limit": "Limit", "order": "Sorrend", "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g" - } + }, + "title": "Adja meg az Transmission be\u00e1ll\u00edt\u00e1sokat" } } } diff --git a/homeassistant/components/transmission/translations/zh-Hans.json b/homeassistant/components/transmission/translations/zh-Hans.json index d217ccdc842..a056b99a4bb 100644 --- a/homeassistant/components/transmission/translations/zh-Hans.json +++ b/homeassistant/components/transmission/translations/zh-Hans.json @@ -1,10 +1,34 @@ { "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548", + "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + }, "step": { "user": { "data": { - "password": "\u5bc6\u7801" - } + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + }, + "title": "\u914d\u7f6e Transmission \u5ba2\u6237\u7aef" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "limit": "\u9650\u5236", + "scan_interval": "\u66f4\u65b0\u9891\u7387" + }, + "title": "Transmission \u914d\u7f6e\u9009\u9879" } } } diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index be76999ec3f..0ebb2b39cb8 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -81,7 +81,7 @@ class TransportNSWSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -101,7 +101,7 @@ class TransportNSWSensor(SensorEntity): } @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index 82b158aa0ec..427283260e0 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -1,4 +1,6 @@ """This component provides HA sensor support for Travis CI framework.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,11 @@ from travispy import TravisPy from travispy.errors import TravisError 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_API_KEY, @@ -27,15 +33,41 @@ DEFAULT_BRANCH_NAME = "master" SCAN_INTERVAL = timedelta(seconds=30) -# sensor_type [ description, unit, icon ] -SENSOR_TYPES = { - "last_build_id": ["Last Build ID", "", "mdi:card-account-details"], - "last_build_duration": ["Last Build Duration", TIME_SECONDS, "mdi:timelapse"], - "last_build_finished_at": ["Last Build Finished At", "", "mdi:timetable"], - "last_build_started_at": ["Last Build Started At", "", "mdi:timetable"], - "last_build_state": ["Last Build State", "", "mdi:github"], - "state": ["State", "", "mdi:github"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="last_build_id", + name="Last Build ID", + icon="mdi:card-account-details", + ), + SensorEntityDescription( + key="last_build_duration", + name="Last Build Duration", + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timelapse", + ), + SensorEntityDescription( + key="last_build_finished_at", + name="Last Build Finished At", + icon="mdi:timetable", + ), + SensorEntityDescription( + key="last_build_started_at", + name="Last Build Started At", + icon="mdi:timetable", + ), + SensorEntityDescription( + key="last_build_state", + name="Last Build State", + icon="mdi:github", + ), + SensorEntityDescription( + key="state", + name="State", + icon="mdi:github", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] NOTIFICATION_ID = "travisci" NOTIFICATION_TITLE = "Travis CI Sensor Setup" @@ -43,8 +75,8 @@ NOTIFICATION_TITLE = "Travis CI Sensor Setup" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, - 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)] ), vol.Required(CONF_BRANCH, default=DEFAULT_BRANCH_NAME): cv.string, vol.Optional(CONF_REPOSITORY, default=[]): vol.All(cv.ensure_list, [cv.string]), @@ -56,9 +88,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Travis CI sensor.""" - token = config.get(CONF_API_KEY) - repositories = config.get(CONF_REPOSITORY) - branch = config.get(CONF_BRANCH) + token = config[CONF_API_KEY] + repositories = config[CONF_REPOSITORY] + branch = config[CONF_BRANCH] try: travis = TravisPy.github_auth(token) @@ -75,52 +107,43 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) return False - sensors = [] - # non specific repository selected, then show all associated if not repositories: all_repos = travis.repos(member=user.login) repositories = [repo.slug for repo in all_repos] + entities = [] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] for repo in repositories: if "/" not in repo: repo = f"{user.login}/{repo}" - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - sensors.append(TravisCISensor(travis, repo, user, branch, sensor_type)) + entities.extend( + [ + TravisCISensor(travis, repo, user, branch, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions + ] + ) - add_entities(sensors, True) - return True + add_entities(entities, True) class TravisCISensor(SensorEntity): """Representation of a Travis CI sensor.""" - def __init__(self, data, repo_name, user, branch, sensor_type): + def __init__( + self, data, repo_name, user, branch, description: SensorEntityDescription + ): """Initialize the sensor.""" + self.entity_description = description self._build = None - self._sensor_type = sensor_type self._data = data self._repo_name = repo_name self._user = user self._branch = branch - self._state = None - self._name = f"{self._repo_name} {SENSOR_TYPES[self._sensor_type][0]}" - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self._sensor_type][1] - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"{repo_name} {description.name}" @property def extra_state_attributes(self): @@ -128,8 +151,8 @@ class TravisCISensor(SensorEntity): attrs = {} attrs[ATTR_ATTRIBUTION] = ATTRIBUTION - if self._build and self._state is not None: - if self._user and self._sensor_type == "state": + if self._build and self._attr_native_value is not None: + if self._user and self.entity_description.key == "state": attrs["Owner Name"] = self._user.name attrs["Owner Email"] = self._user.email else: @@ -141,23 +164,19 @@ class TravisCISensor(SensorEntity): return attrs - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return SENSOR_TYPES[self._sensor_type][2] - def update(self): """Get the latest data and updates the states.""" - _LOGGER.debug("Updating sensor %s", self._name) + _LOGGER.debug("Updating sensor %s", self.name) repo = self._data.repo(self._repo_name) self._build = self._data.build(repo.last_build_id) if self._build: - if self._sensor_type == "state": + sensor_type = self.entity_description.key + if sensor_type == "state": branch_stats = self._data.branch(self._branch, self._repo_name) - self._state = branch_stats.state + self._attr_native_value = branch_stats.state else: - param = self._sensor_type.replace("last_build_", "") - self._state = getattr(self._build, param) + param = sensor_type.replace("last_build_", "") + self._attr_native_value = getattr(self._build, param) diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 970a1c54f1e..476a2295fc4 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Tuya.""" +from __future__ import annotations + import logging +from typing import Any from tuyaha import TuyaApi from tuyaha.tuyaapi import ( @@ -155,7 +158,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry self._conf_devs_id = None - self._conf_devs_option = {} + self._conf_devs_option: dict[str, Any] = {} self._form_error = None def _get_form_error(self): diff --git a/homeassistant/components/tuya/translations/en_GB.json b/homeassistant/components/tuya/translations/en_GB.json new file mode 100644 index 00000000000..90df003a190 --- /dev/null +++ b/homeassistant/components/tuya/translations/en_GB.json @@ -0,0 +1,14 @@ +{ + "options": { + "step": { + "device": { + "data": { + "max_kelvin": "Max colour temperature supported in Kelvin", + "min_kelvin": "Min colour temperature supported in Kelvin", + "support_color": "Force colour support", + "tuya_max_coltemp": "Max colour temperature reported by device" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tuya/translations/nl.json b/homeassistant/components/tuya/translations/nl.json index ed0488f524d..56b2ae8236f 100644 --- a/homeassistant/components/tuya/translations/nl.json +++ b/homeassistant/components/tuya/translations/nl.json @@ -52,7 +52,7 @@ }, "init": { "data": { - "discovery_interval": "Polling-interval van ontdekt apparaat in seconden", + "discovery_interval": "Polling-interval van nieuwe apparaten in seconden", "list_devices": "Selecteer de te configureren apparaten of laat leeg om de configuratie op te slaan", "query_device": "Selecteer apparaat dat query-methode zal gebruiken voor snellere statusupdate", "query_interval": "Peilinginterval van het apparaat in seconden" diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index cc17bf6f1a2..0069c3db93c 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -132,7 +132,7 @@ class TwenteMilieuSensor(SensorEntity): self.async_schedule_update_ha_state(True) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/twentemilieu/translations/hu.json b/homeassistant/components/twentemilieu/translations/hu.json index df83a29ec22..637dadb5baf 100644 --- a/homeassistant/components/twentemilieu/translations/hu.json +++ b/homeassistant/components/twentemilieu/translations/hu.json @@ -4,14 +4,18 @@ "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_address": "A c\u00edm nem tal\u00e1lhat\u00f3 a Twente Milieu szolg\u00e1ltat\u00e1si ter\u00fcleten." }, "step": { "user": { "data": { + "house_letter": "H\u00e1zlev\u00e9l/kieg\u00e9sz\u00edt\u0151", "house_number": "h\u00e1zsz\u00e1m", "post_code": "ir\u00e1ny\u00edt\u00f3sz\u00e1m" - } + }, + "description": "\u00c1ll\u00edtsa be a Twente Milieu szolg\u00e1ltat\u00e1st, amely hullad\u00e9kgy\u0171jt\u00e9si inform\u00e1ci\u00f3kat biztos\u00edt a c\u00edm\u00e9re.", + "title": "Twente Milieu" } } } diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index cfabcf1045f..15581e11c28 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -77,7 +77,7 @@ class TwitchSensor(SensorEntity): return self._channel.display_name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index c66db9bb24b..69e4f0df99b 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -93,7 +93,7 @@ class UkTransportSensor(SensorEntity): TRANSPORT_API_URL_BASE = "https://transportapi.com/v3/uk/" _attr_icon = "mdi:train" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__(self, name, api_app_id, api_app_key, url): """Initialize the sensor.""" @@ -110,7 +110,7 @@ class UkTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 338f695a2b4..6a009415163 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -86,7 +86,7 @@ class UniFiBandwidthSensor(UniFiClient, SensorEntity): DOMAIN = DOMAIN - _attr_unit_of_measurement = DATA_MEGABYTES + _attr_native_unit_of_measurement = DATA_MEGABYTES @property def name(self) -> str: @@ -105,7 +105,7 @@ class UniFiRxBandwidthSensor(UniFiBandwidthSensor): TYPE = RX_SENSOR @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" if self._is_wired: return self.client.wired_rx_bytes / 1000000 @@ -118,7 +118,7 @@ class UniFiTxBandwidthSensor(UniFiBandwidthSensor): TYPE = TX_SENSOR @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" if self._is_wired: return self.client.wired_tx_bytes / 1000000 @@ -167,7 +167,7 @@ class UniFiUpTimeSensor(UniFiClient, SensorEntity): return f"{super().name} {self.TYPE.capitalize()}" @property - def state(self) -> datetime: + def native_value(self) -> datetime: """Return the uptime of the client.""" if self.client.uptime < 1000000000: return (dt_util.now() - timedelta(seconds=self.client.uptime)).isoformat() diff --git a/homeassistant/components/unifi/translations/he.json b/homeassistant/components/unifi/translations/he.json index 4fe52a3cf8b..83c34cb9c77 100644 --- a/homeassistant/components/unifi/translations/he.json +++ b/homeassistant/components/unifi/translations/he.json @@ -33,14 +33,6 @@ "track_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9 \u05e8\u05e9\u05ea (\u05d4\u05ea\u05e7\u05e0\u05d9 Ubiquiti)" } }, - "init": { - "data": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - } - }, "simple_options": { "data": { "block_client": "\u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05de\u05d1\u05d5\u05e7\u05e8\u05d9\u05dd \u05e9\u05dc \u05d2\u05d9\u05e9\u05d4 \u05dc\u05e8\u05e9\u05ea", diff --git a/homeassistant/components/unifi/translations/hu.json b/homeassistant/components/unifi/translations/hu.json index 5c174e9939d..22904c8ec7b 100644 --- a/homeassistant/components/unifi/translations/hu.json +++ b/homeassistant/components/unifi/translations/hu.json @@ -7,7 +7,8 @@ }, "error": { "faulty_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "service_unavailable": "Sikertelen csatlakoz\u00e1s" + "service_unavailable": "Sikertelen csatlakoz\u00e1s", + "unknown_client_mac": "Nincs el\u00e9rhet\u0151 \u00fcgyf\u00e9l ezen a MAC-c\u00edmen" }, "flow_title": "{site} ({host})", "step": { @@ -28,18 +29,46 @@ "step": { "client_control": { "data": { - "dpi_restrictions": "Enged\u00e9lyezze a DPI restrikci\u00f3s csoportok vez\u00e9rl\u00e9s\u00e9t" + "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" }, - "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." + "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" + }, + "device_tracker": { + "data": { + "detection_time": "Id\u0151 m\u00e1sodpercben az utols\u00f3 l\u00e1t\u00e1st\u00f3l a t\u00e1vol tart\u00e1sig", + "ignore_wired_bug": "Az UniFi vezet\u00e9kes hibalogika letilt\u00e1sa", + "ssid_filter": "V\u00e1lassza ki az SSID -ket a vezet\u00e9k n\u00e9lk\u00fcli \u00fcgyfelek nyomon k\u00f6vet\u00e9s\u00e9hez", + "track_clients": "K\u00f6vesse nyomon a h\u00e1l\u00f3zati \u00fcgyfeleket", + "track_devices": "H\u00e1l\u00f3zati eszk\u00f6z\u00f6k nyomon k\u00f6vet\u00e9se (Ubiquiti eszk\u00f6z\u00f6k)", + "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" + }, + "init": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + } }, "simple_options": { + "data": { + "block_client": "H\u00e1l\u00f3zathozz\u00e1f\u00e9r\u00e9s vez\u00e9relt \u00fcgyfelek", + "track_clients": "K\u00f6vesse nyomon a h\u00e1l\u00f3zati \u00fcgyfeleket", + "track_devices": "H\u00e1l\u00f3zati eszk\u00f6z\u00f6k nyomon k\u00f6vet\u00e9se (Ubiquiti eszk\u00f6z\u00f6k)" + }, "description": "UniFi integr\u00e1ci\u00f3 konfigur\u00e1l\u00e1sa" }, "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_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" } } } diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index f3d4a6f1355..2e3e6892c1c 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -612,7 +612,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): async def async_toggle(self): """Toggle the power on the media player.""" - await self._async_call_service(SERVICE_TOGGLE, allow_override=True) + if SERVICE_TOGGLE in self._cmds: + await self._async_call_service(SERVICE_TOGGLE, allow_override=True) + else: + # Delegate to turn_on or turn_off by default + await super().async_toggle() async def async_update(self): """Update state in HA.""" diff --git a/homeassistant/components/upb/translations/hu.json b/homeassistant/components/upb/translations/hu.json index b09f497a0e4..58b81af7be8 100644 --- a/homeassistant/components/upb/translations/hu.json +++ b/homeassistant/components/upb/translations/hu.json @@ -5,13 +5,18 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_upb_file": "Hi\u00e1nyz\u00f3 vagy \u00e9rv\u00e9nytelen UPB UPStart export f\u00e1jl, ellen\u0151rizze a f\u00e1jl nev\u00e9t \u00e9s el\u00e9r\u00e9si \u00fatj\u00e1t.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { "user": { "data": { + "address": "C\u00edm (l\u00e1sd a fenti le\u00edr\u00e1st)", + "file_path": "Az UPStart UPB exportf\u00e1jl el\u00e9r\u00e9si \u00fatja \u00e9s neve.", "protocol": "Protokoll" - } + }, + "description": "Csatlakoztasson egy univerz\u00e1lis Powerline Bus Powerline Interface modult (UPB PIM). A c\u00edmsornak a \u201etcp\u201d \u201ec\u00edm [: port]\u201d form\u00e1tum\u00fanak kell lennie. A port nem k\u00f6telez\u0151, \u00e9s alap\u00e9rtelmezett \u00e9rt\u00e9ke 2101. P\u00e9lda: '192.168.1.42'. A soros protokollhoz a c\u00edmnek 'tty [: baud]' form\u00e1tum\u00fanak kell lennie. A baud opcion\u00e1lis, \u00e9s alap\u00e9rtelmezett \u00e9rt\u00e9ke 4800. P\u00e9lda: '/dev/ttyS1'.", + "title": "Csatlakoz\u00e1s az UPB PIM-hez" } } } diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 636fa7a2b8a..82d42e28589 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -8,11 +8,10 @@ from typing import Any, Dict import requests.exceptions import upcloud_api -import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -23,18 +22,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant 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.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -58,21 +55,6 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update" STATE_MAP = {"error": STATE_PROBLEM, "started": STATE_ON, "stopped": STATE_OFF} -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 - ): cv.time_period, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - class UpCloudDataUpdateCoordinator( DataUpdateCoordinator[Dict[str, upcloud_api.Server]] @@ -115,37 +97,6 @@ class UpCloudHassData: coordinators: dict[str, UpCloudDataUpdateCoordinator] = dataclasses.field( default_factory=dict ) - scan_interval_migrations: dict[str, int] = dataclasses.field(default_factory=dict) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up UpCloud component.""" - domain_config = config.get(DOMAIN) - if not domain_config: - return True - - _LOGGER.warning( - "Loading upcloud via top level config is deprecated and no longer " - "necessary as of 0.117; Please remove it from your YAML configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_USERNAME: domain_config[CONF_USERNAME], - CONF_PASSWORD: domain_config[CONF_PASSWORD], - }, - ) - ) - - if domain_config[CONF_SCAN_INTERVAL]: - hass.data[DATA_UPCLOUD] = UpCloudHassData() - hass.data[DATA_UPCLOUD].scan_interval_migrations[ - domain_config[CONF_USERNAME] - ] = domain_config[CONF_SCAN_INTERVAL] - - return True def _config_entry_update_signal_name(config_entry: ConfigEntry) -> str: @@ -178,22 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to connect", exc_info=True) raise ConfigEntryNotReady from err - upcloud_data = hass.data.setdefault(DATA_UPCLOUD, UpCloudHassData()) - - # Handle pre config entry (0.117) scan interval migration to options - migrated_scan_interval = upcloud_data.scan_interval_migrations.pop( - entry.data[CONF_USERNAME], None - ) - if migrated_scan_interval and ( - not entry.options.get(CONF_SCAN_INTERVAL) - or entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL.total_seconds() - ): - update_interval = migrated_scan_interval - hass.config_entries.async_update_entry( - entry, - options={CONF_SCAN_INTERVAL: update_interval.total_seconds()}, - ) - elif entry.options.get(CONF_SCAN_INTERVAL): + if entry.options.get(CONF_SCAN_INTERVAL): update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL]) else: update_interval = DEFAULT_SCAN_INTERVAL @@ -218,7 +154,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - upcloud_data.coordinators[entry.data[CONF_USERNAME]] = coordinator + hass.data[DATA_UPCLOUD] = UpCloudHassData() + hass.data[DATA_UPCLOUD].coordinators[entry.data[CONF_USERNAME]] = coordinator # Forward entry setup hass.config_entries.async_setup_platforms(entry, CONFIG_ENTRY_DOMAINS) @@ -286,7 +223,7 @@ class UpCloudServerEntity(CoordinatorEntity): """Return True if entity is available.""" return super().available and STATE_MAP.get( self._server.state, self._server.state - ) in [STATE_ON, STATE_OFF] + ) in (STATE_ON, STATE_OFF) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index 1a16a78cfa1..e6868be29b9 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -57,13 +57,6 @@ class UpCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import initiated flow.""" - await self.async_set_unique_id(user_input[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - return await self.async_step_user(user_input=user_input) - @callback def _async_show_form( self, diff --git a/homeassistant/components/updater/binary_sensor.py b/homeassistant/components/updater/binary_sensor.py index 93d19029992..25339f6308a 100644 --- a/homeassistant/components/updater/binary_sensor.py +++ b/homeassistant/components/updater/binary_sensor.py @@ -1,6 +1,10 @@ """Support for Home Assistant Updater binary sensors.""" +from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_UPDATE, + BinarySensorEntity, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ATTR_NEWEST_VERSION, ATTR_RELEASE_NOTES, DOMAIN as UPDATER_DOMAIN @@ -17,25 +21,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class UpdaterBinary(CoordinatorEntity, BinarySensorEntity): """Representation of an updater binary sensor.""" - @property - def name(self) -> str: - """Return the name of the binary sensor, if any.""" - return "Updater" + _attr_device_class = DEVICE_CLASS_UPDATE + _attr_name = "Updater" + _attr_unique_id = "updater" @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "updater" - - @property - def is_on(self) -> bool: + 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 @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict | None: """Return the optional state attributes.""" if not self.coordinator.data: return None diff --git a/homeassistant/components/updater/manifest.json b/homeassistant/components/updater/manifest.json index 9996d2bb1f0..db225bbf242 100644 --- a/homeassistant/components/updater/manifest.json +++ b/homeassistant/components/updater/manifest.json @@ -2,7 +2,6 @@ "domain": "updater", "name": "Updater", "documentation": "https://www.home-assistant.io/integrations/updater", - "requirements": ["distro==1.5.0"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "cloud_polling" diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 6ad7111ae12..80a7753ec8c 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -1,6 +1,11 @@ """Open ports in your router for Home Assistant and provide statistics.""" +from __future__ import annotations + import asyncio +from collections.abc import Mapping +from datetime import timedelta from ipaddress import ip_address +from typing import Any import voluptuous as vol @@ -9,28 +14,34 @@ from homeassistant.components import ssdp from homeassistant.components.network import async_get_source_ip from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import ( CONF_LOCAL_IP, CONFIG_ENTRY_HOSTNAME, + CONFIG_ENTRY_SCAN_INTERVAL, CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, + DEFAULT_SCAN_INTERVAL, DOMAIN, DOMAIN_CONFIG, DOMAIN_DEVICES, DOMAIN_LOCAL_IP, - LOGGER as _LOGGER, + LOGGER, ) from .device import Device NOTIFICATION_ID = "upnp_notification" NOTIFICATION_TITLE = "UPnP/IGD Setup" -PLATFORMS = ["sensor"] +PLATFORMS = ["binary_sensor", "sensor"] CONFIG_SCHEMA = vol.Schema( { @@ -44,24 +55,9 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_construct_device(hass: HomeAssistant, udn: str, st: str) -> Device: - """Discovery devices and construct a Device for one.""" - # pylint: disable=invalid-name - _LOGGER.debug("Constructing device: %s::%s", udn, st) - discovery_info = ssdp.async_get_discovery_info_by_udn_st(hass, udn, st) - - if not discovery_info: - _LOGGER.info("Device not discovered") - return None - - return await Device.async_create_device( - hass, discovery_info[ssdp.ATTR_SSDP_LOCATION] - ) - - -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up UPnP component.""" - _LOGGER.debug("async_setup, config: %s", config) + 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) @@ -84,26 +80,50 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" - _LOGGER.debug("Setting up config entry: %s", entry.unique_id) + LOGGER.debug("Setting up config entry: %s", entry.unique_id) - # Discover and construct. udn = entry.data[CONFIG_ENTRY_UDN] st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name + usn = f"{udn}::{st}" + + # Register device discovered-callback. + device_discovered_event = asyncio.Event() + discovery_info: Mapping[str, Any] | None = None + + @callback + def device_discovered(info: Mapping[str, Any]) -> None: + nonlocal discovery_info + LOGGER.debug( + "Device discovered: %s, at: %s", usn, info[ssdp.ATTR_SSDP_LOCATION] + ) + discovery_info = info + device_discovered_event.set() + + cancel_discovered_callback = ssdp.async_register_callback( + hass, + device_discovered, + { + "usn": usn, + }, + ) + try: - device = await async_construct_device(hass, udn, st) + await asyncio.wait_for(device_discovered_event.wait(), timeout=10) except asyncio.TimeoutError as err: + LOGGER.debug("Device not discovered: %s", usn) raise ConfigEntryNotReady from err + finally: + cancel_discovered_callback() - if not device: - _LOGGER.info("Unable to create UPnP/IGD, aborting") - raise ConfigEntryNotReady - - # Save device. - hass.data[DOMAIN][DOMAIN_DEVICES][device.udn] = device + # Create device. + location = discovery_info[ # pylint: disable=unsubscriptable-object + ssdp.ATTR_SSDP_LOCATION + ] + device = await Device.async_create_device(hass, location) # Ensure entry has a unique_id. if not entry.unique_id: - _LOGGER.debug( + LOGGER.debug( "Setting unique_id: %s, for config_entry: %s", device.unique_id, entry, @@ -134,8 +154,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=device.model_name, ) + update_interval_sec = entry.options.get( + CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + update_interval = timedelta(seconds=update_interval_sec) + LOGGER.debug("update_interval: %s", update_interval) + coordinator = UpnpDataUpdateCoordinator( + hass, + device=device, + update_interval=update_interval, + ) + + # Save coordinator. + hass.data[DOMAIN][entry.entry_id] = coordinator + + await coordinator.async_config_entry_first_refresh() + # Create sensors. - _LOGGER.debug("Enabling sensors") + LOGGER.debug("Enabling sensors") hass.config_entries.async_setup_platforms(entry, PLATFORMS) # Start device updater. @@ -146,14 +182,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a UPnP/IGD device from a config entry.""" - _LOGGER.debug("Unloading config entry: %s", config_entry.unique_id) + LOGGER.debug("Unloading config entry: %s", config_entry.unique_id) - udn = config_entry.data.get(CONFIG_ENTRY_UDN) - if udn in hass.data[DOMAIN][DOMAIN_DEVICES]: - device = hass.data[DOMAIN][DOMAIN_DEVICES][udn] - await device.async_stop() + if coordinator := hass.data[DOMAIN].pop(config_entry.entry_id, None): + await coordinator.device.async_stop() - del hass.data[DOMAIN][DOMAIN_DEVICES][udn] - - _LOGGER.debug("Deleting sensors") + LOGGER.debug("Deleting sensors") return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +class UpnpDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to update data from UPNP device.""" + + def __init__( + self, hass: HomeAssistant, device: Device, update_interval: timedelta + ) -> None: + """Initialize.""" + self.device = device + + super().__init__( + hass, LOGGER, name=device.name, update_interval=update_interval + ) + + async def _async_update_data(self) -> Mapping[str, Any]: + """Update data.""" + update_values = await asyncio.gather( + self.device.async_get_traffic_data(), + self.device.async_get_status(), + ) + + data = dict(update_values[0]) + data.update(update_values[1]) + + return data + + +class UpnpEntity(CoordinatorEntity): + """Base class for UPnP/IGD entities.""" + + coordinator: UpnpDataUpdateCoordinator + + def __init__(self, coordinator: UpnpDataUpdateCoordinator) -> None: + """Initialize the base entities.""" + super().__init__(coordinator) + self._device = coordinator.device + self._attr_device_info = { + "connections": {(dr.CONNECTION_UPNP, coordinator.device.udn)}, + "name": coordinator.device.name, + "manufacturer": coordinator.device.manufacturer, + "model": coordinator.device.model_name, + } diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py new file mode 100644 index 00000000000..2f2f0af0e96 --- /dev/null +++ b/homeassistant/components/upnp/binary_sensor.py @@ -0,0 +1,54 @@ +"""Support for UPnP/IGD Binary Sensors.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorEntity, +) +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 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the UPnP/IGD sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + LOGGER.debug("Adding binary sensor") + + sensors = [ + UpnpStatusBinarySensor(coordinator), + ] + async_add_entities(sensors) + + +class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity): + """Class for UPnP/IGD binary sensors.""" + + _attr_device_class = DEVICE_CLASS_CONNECTIVITY + + def __init__( + self, + coordinator: UpnpDataUpdateCoordinator, + ) -> 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) + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.coordinator.data[WANSTATUS] == "Connected" diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 0679d9ffcb5..5df4e267427 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -1,6 +1,7 @@ """Config flow for UPNP.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from datetime import timedelta from typing import Any @@ -10,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from .const import ( CONFIG_ENTRY_HOSTNAME, @@ -18,18 +19,69 @@ from .const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, - DISCOVERY_HOSTNAME, - DISCOVERY_LOCATION, - DISCOVERY_NAME, - DISCOVERY_ST, - DISCOVERY_UDN, - DISCOVERY_UNIQUE_ID, - DISCOVERY_USN, DOMAIN, - DOMAIN_DEVICES, - LOGGER as _LOGGER, + LOGGER, + SSDP_SEARCH_TIMEOUT, + ST_IGD_V1, + ST_IGD_V2, ) -from .device import Device, discovery_info_to_discovery + + +def _friendly_name_from_discovery(discovery_info: Mapping[str, Any]) -> str: + """Extract user-friendly name from discovery.""" + return ( + discovery_info.get("friendlyName") + or discovery_info.get("modeName") + or discovery_info.get("_host", "") + ) + + +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: + LOGGER.info( + "Device discovered: %s, at: %s", + info[ssdp.ATTR_SSDP_USN], + info[ssdp.ATTR_SSDP_LOCATION], + ) + device_discovered_event.set() + + cancel_discovered_callback_1 = ssdp.async_register_callback( + hass, + device_discovered, + { + ssdp.ATTR_SSDP_ST: ST_IGD_V1, + }, + ) + cancel_discovered_callback_2 = ssdp.async_register_callback( + hass, + device_discovered, + { + ssdp.ATTR_SSDP_ST: ST_IGD_V2, + }, + ) + + try: + await asyncio.wait_for( + device_discovered_event.wait(), timeout=SSDP_SEARCH_TIMEOUT + ) + except asyncio.TimeoutError: + return False + finally: + cancel_discovered_callback_1() + cancel_discovered_callback_2() + + return True + + +def _discovery_igd_devices(hass: HomeAssistant) -> list[Mapping[str, Any]]: + """Discovery IGD devices.""" + return ssdp.async_get_discovery_info_by_st( + hass, ST_IGD_V1 + ) + ssdp.async_get_discovery_info_by_st(hass, ST_IGD_V2) class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -50,29 +102,26 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Mapping | None = None ) -> Mapping[str, Any]: """Handle a flow start.""" - _LOGGER.debug("async_step_user: user_input: %s", user_input) + LOGGER.debug("async_step_user: user_input: %s", user_input) if user_input is not None: # Ensure wanted device was discovered. matching_discoveries = [ discovery for discovery in self._discoveries - if discovery[DISCOVERY_UNIQUE_ID] == user_input["unique_id"] + if discovery[ssdp.ATTR_SSDP_USN] == user_input["unique_id"] ] if not matching_discoveries: return self.async_abort(reason="no_devices_found") discovery = matching_discoveries[0] await self.async_set_unique_id( - discovery[DISCOVERY_UNIQUE_ID], raise_on_progress=False + discovery[ssdp.ATTR_SSDP_USN], raise_on_progress=False ) return await self._async_create_entry_from_discovery(discovery) # Discover devices. - discoveries = [ - await Device.async_supplement_discovery(self.hass, discovery) - for discovery in await Device.async_discover(self.hass) - ] + discoveries = _discovery_igd_devices(self.hass) # Store discoveries which have not been configured. current_unique_ids = { @@ -81,7 +130,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._discoveries = [ discovery for discovery in discoveries - if discovery[DISCOVERY_UNIQUE_ID] not in current_unique_ids + if discovery[ssdp.ATTR_SSDP_USN] not in current_unique_ids ] # Ensure anything to add. @@ -92,7 +141,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required("unique_id"): vol.In( { - discovery[DISCOVERY_UNIQUE_ID]: discovery[DISCOVERY_NAME] + discovery[ssdp.ATTR_SSDP_USN]: _friendly_name_from_discovery( + discovery + ) for discovery in self._discoveries } ), @@ -110,36 +161,36 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): configured before, find any device and create a config_entry for it. Otherwise, do nothing. """ - _LOGGER.debug("async_step_import: import_info: %s", import_info) + LOGGER.debug("async_step_import: import_info: %s", import_info) # Landed here via configuration.yaml entry. # Any device already added, then abort. if self._async_current_entries(): - _LOGGER.debug("Already configured, aborting") + LOGGER.debug("Already configured, aborting") return self.async_abort(reason="already_configured") # Discover devices. - self._discoveries = await Device.async_discover(self.hass) + await _async_wait_for_discoveries(self.hass) + discoveries = _discovery_igd_devices(self.hass) # Ensure anything to add. If not, silently abort. - if not self._discoveries: - _LOGGER.info("No UPnP devices discovered, aborting") + if not discoveries: + LOGGER.info("No UPnP devices discovered, aborting") return self.async_abort(reason="no_devices_found") # Ensure complete discovery. - discovery = self._discoveries[0] + discovery = discoveries[0] if ( - DISCOVERY_UDN not in discovery - or DISCOVERY_ST not in discovery - or DISCOVERY_LOCATION not in discovery - or DISCOVERY_USN not in discovery + ssdp.ATTR_UPNP_UDN not in discovery + or ssdp.ATTR_SSDP_ST not in discovery + or ssdp.ATTR_SSDP_LOCATION not in discovery + or ssdp.ATTR_SSDP_USN not in discovery ): - _LOGGER.debug("Incomplete discovery, ignoring") + LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") # Ensure not already configuring/configured. - discovery = await Device.async_supplement_discovery(self.hass, discovery) - unique_id = discovery[DISCOVERY_UNIQUE_ID] + unique_id = discovery[ssdp.ATTR_SSDP_USN] await self.async_set_unique_id(unique_id) return await self._async_create_entry_from_discovery(discovery) @@ -150,7 +201,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): This flow is triggered by the SSDP component. It will check if the host is already configured and delegate to the import step if not. """ - _LOGGER.debug("async_step_ssdp: discovery_info: %s", discovery_info) + LOGGER.debug("async_step_ssdp: discovery_info: %s", discovery_info) # Ensure complete discovery. if ( @@ -159,38 +210,31 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): or ssdp.ATTR_SSDP_LOCATION not in discovery_info or ssdp.ATTR_SSDP_USN not in discovery_info ): - _LOGGER.debug("Incomplete discovery, ignoring") + LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") - # Convert to something we understand/speak. - discovery = discovery_info_to_discovery(discovery_info) - # Ensure not already configuring/configured. - unique_id = discovery[DISCOVERY_USN] + unique_id = discovery_info[ssdp.ATTR_SSDP_USN] await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured( - updates={CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME]} - ) + hostname = discovery_info["_host"] + self._abort_if_unique_id_configured(updates={CONFIG_ENTRY_HOSTNAME: hostname}) - # Handle devices changing their UDN, only allow a single + # Handle devices changing their UDN, only allow a single host. existing_entries = self._async_current_entries() for config_entry in existing_entries: entry_hostname = config_entry.data.get(CONFIG_ENTRY_HOSTNAME) - if entry_hostname == discovery[DISCOVERY_HOSTNAME]: - _LOGGER.debug( + if entry_hostname == hostname: + LOGGER.debug( "Found existing config_entry with same hostname, discovery ignored" ) return self.async_abort(reason="discovery_ignored") - # Get more data about the device. - discovery = await Device.async_supplement_discovery(self.hass, discovery) - # Store discovery. - self._discoveries = [discovery] + self._discoveries = [discovery_info] # Ensure user recognizable. self.context["title_placeholders"] = { - "name": discovery[DISCOVERY_NAME], + "name": _friendly_name_from_discovery(discovery_info), } return await self.async_step_ssdp_confirm() @@ -199,7 +243,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Mapping | None = None ) -> Mapping[str, Any]: """Confirm integration via SSDP.""" - _LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) + LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) if user_input is None: return self.async_show_form(step_id="ssdp_confirm") @@ -219,16 +263,16 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery: Mapping, ) -> Mapping[str, Any]: """Create an entry from discovery.""" - _LOGGER.debug( + LOGGER.debug( "_async_create_entry_from_discovery: discovery: %s", discovery, ) - title = discovery.get(DISCOVERY_NAME, "") + title = _friendly_name_from_discovery(discovery) data = { - CONFIG_ENTRY_UDN: discovery[DISCOVERY_UDN], - CONFIG_ENTRY_ST: discovery[DISCOVERY_ST], - CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME], + CONFIG_ENTRY_UDN: discovery["_udn"], + CONFIG_ENTRY_ST: discovery[ssdp.ATTR_SSDP_ST], + CONFIG_ENTRY_HOSTNAME: discovery["_host"], } return self.async_create_entry(title=title, data=data) @@ -243,13 +287,12 @@ class UpnpOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init(self, user_input: Mapping = None) -> None: """Manage the options.""" if user_input is not None: - udn = self.config_entry.data[CONFIG_ENTRY_UDN] - coordinator = self.hass.data[DOMAIN][DOMAIN_DEVICES][udn].coordinator + coordinator = self.hass.data[DOMAIN][self.config_entry.entry_id] update_interval_sec = user_input.get( CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) update_interval = timedelta(seconds=update_interval_sec) - _LOGGER.debug("Updating coordinator, update_interval: %s", update_interval) + LOGGER.debug("Updating coordinator, update_interval: %s", update_interval) coordinator.update_interval = update_interval return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 0611176350a..769e398c5a4 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -18,17 +18,16 @@ 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" KIBIBYTE = 1024 UPDATE_INTERVAL = timedelta(seconds=30) -DISCOVERY_HOSTNAME = "hostname" -DISCOVERY_LOCATION = "location" -DISCOVERY_NAME = "name" -DISCOVERY_ST = "st" -DISCOVERY_UDN = "udn" -DISCOVERY_UNIQUE_ID = "unique_id" -DISCOVERY_USN = "usn" CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval" CONFIG_ENTRY_ST = "st" CONFIG_ENTRY_UDN = "udn" CONFIG_ENTRY_HOSTNAME = "hostname" DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).total_seconds() +ST_IGD_V1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" +ST_IGD_V2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2" +SSDP_SEARCH_TIMEOUT = 4 diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index cf76aa41f8a..ca06f501405 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -12,7 +12,6 @@ from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.device_updater import DeviceUpdater from async_upnp_client.profiles.igd import IgdDevice -from homeassistant.components import ssdp from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -22,36 +21,18 @@ from .const import ( BYTES_RECEIVED, BYTES_SENT, CONF_LOCAL_IP, - DISCOVERY_HOSTNAME, - DISCOVERY_LOCATION, - DISCOVERY_NAME, - DISCOVERY_ST, - DISCOVERY_UDN, - DISCOVERY_UNIQUE_ID, - DISCOVERY_USN, DOMAIN, DOMAIN_CONFIG, LOGGER as _LOGGER, PACKETS_RECEIVED, PACKETS_SENT, TIMESTAMP, + UPTIME, + WANIP, + WANSTATUS, ) -def discovery_info_to_discovery(discovery_info: Mapping) -> Mapping: - """Convert a SSDP-discovery to 'our' discovery.""" - location = discovery_info[ssdp.ATTR_SSDP_LOCATION] - parsed = urlparse(location) - hostname = parsed.hostname - return { - DISCOVERY_UDN: discovery_info[ssdp.ATTR_UPNP_UDN], - DISCOVERY_ST: discovery_info[ssdp.ATTR_SSDP_ST], - DISCOVERY_LOCATION: discovery_info[ssdp.ATTR_SSDP_LOCATION], - DISCOVERY_USN: discovery_info[ssdp.ATTR_SSDP_USN], - DISCOVERY_HOSTNAME: hostname, - } - - 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]: @@ -70,29 +51,6 @@ class Device: self._device_updater = device_updater self.coordinator: DataUpdateCoordinator = None - @classmethod - async def async_discover(cls, hass: HomeAssistant) -> list[Mapping]: - """Discover UPnP/IGD devices.""" - _LOGGER.debug("Discovering UPnP/IGD devices") - discoveries = [] - for ssdp_st in IgdDevice.DEVICE_TYPES: - for discovery_info in ssdp.async_get_discovery_info_by_st(hass, ssdp_st): - discoveries.append(discovery_info_to_discovery(discovery_info)) - return discoveries - - @classmethod - async def async_supplement_discovery( - cls, hass: HomeAssistant, discovery: Mapping - ) -> Mapping: - """Get additional data from device and supplement discovery.""" - location = discovery[DISCOVERY_LOCATION] - device = await Device.async_create_device(hass, location) - discovery[DISCOVERY_NAME] = device.name - discovery[DISCOVERY_HOSTNAME] = device.hostname - discovery[DISCOVERY_UNIQUE_ID] = discovery[DISCOVERY_USN] - - return discovery - @classmethod async def async_create_device( cls, hass: HomeAssistant, ssdp_location: str @@ -199,3 +157,18 @@ class Device: PACKETS_RECEIVED: values[2], PACKETS_SENT: values[3], } + + async def async_get_status(self) -> Mapping[str, Any]: + """Get connection status, uptime, and external IP.""" + _LOGGER.debug("Getting status for device: %s", self) + + values = await asyncio.gather( + self._igd_device.async_get_status_info(), + self._igd_device.async_get_external_ip_address(), + ) + + 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], + } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 41d50b4bae8..5f38a827ec7 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,9 +3,9 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.19.1"], + "requirements": ["async-upnp-client==0.20.0"], "dependencies": ["network", "ssdp"], - "codeowners": ["@StevenLooman"], + "codeowners": ["@StevenLooman","@ehendrix23"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 54744490a86..185d3ecac6d 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,38 +1,25 @@ """Support for UPnP/IGD Sensors.""" from __future__ import annotations -from datetime import timedelta -from typing import Any, Mapping - 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.core import HomeAssistant -from homeassistant.helpers import 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 UpnpDataUpdateCoordinator, UpnpEntity from .const import ( BYTES_RECEIVED, BYTES_SENT, - CONFIG_ENTRY_SCAN_INTERVAL, - CONFIG_ENTRY_UDN, DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, - DEFAULT_SCAN_INTERVAL, DOMAIN, - DOMAIN_DEVICES, KIBIBYTE, - LOGGER as _LOGGER, + LOGGER, PACKETS_RECEIVED, PACKETS_SENT, TIMESTAMP, ) -from .device import Device SENSOR_TYPES = { BYTES_RECEIVED: { @@ -78,7 +65,7 @@ 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( + LOGGER.debug( "async_setup_platform: config: %s, discovery: %s", config, discovery_info ) @@ -89,52 +76,36 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" - udn = config_entry.data[CONFIG_ENTRY_UDN] - device: Device = hass.data[DOMAIN][DOMAIN_DEVICES][udn] + coordinator = hass.data[DOMAIN][config_entry.entry_id] - update_interval_sec = config_entry.options.get( - CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - update_interval = timedelta(seconds=update_interval_sec) - _LOGGER.debug("update_interval: %s", update_interval) - _LOGGER.debug("Adding sensors") - coordinator = DataUpdateCoordinator[Mapping[str, Any]]( - hass, - _LOGGER, - name=device.name, - update_method=device.async_get_traffic_data, - update_interval=update_interval, - ) - device.coordinator = coordinator - - await coordinator.async_refresh() + LOGGER.debug("Adding sensors") sensors = [ - RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), - RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_SENT]), - RawUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_RECEIVED]), - RawUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_SENT]), - DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), - DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_SENT]), - DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_RECEIVED]), - DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_SENT]), + 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]), ] - async_add_entities(sensors, True) + async_add_entities(sensors) -class UpnpSensor(CoordinatorEntity, SensorEntity): +class UpnpSensor(UpnpEntity, SensorEntity): """Base class for UPnP/IGD sensors.""" def __init__( self, - coordinator: DataUpdateCoordinator[Mapping[str, Any]], - device: Device, - sensor_type: Mapping[str, str], + coordinator: UpnpDataUpdateCoordinator, + sensor_type: dict[str, str], ) -> None: """Initialize the base sensor.""" super().__init__(coordinator) - self._device = device 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: @@ -144,43 +115,21 @@ class UpnpSensor(CoordinatorEntity, SensorEntity): @property def available(self) -> bool: """Return if entity is available.""" - device_value_key = self._sensor_type["device_value_key"] - return ( - self.coordinator.last_update_success - and device_value_key in self.coordinator.data + return super().available and self.coordinator.data.get( + self._sensor_type["device_value_key"] ) @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.name} {self._sensor_type['name']}" - - @property - def unique_id(self) -> str: - """Return an unique ID.""" - return f"{self._device.udn}_{self._sensor_type['unique_id']}" - - @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._sensor_type["unit"] - @property - def device_info(self) -> DeviceInfo: - """Get device info.""" - return { - "connections": {(dr.CONNECTION_UPNP, self._device.udn)}, - "name": self._device.name, - "manufacturer": self._device.manufacturer, - "model": self._device.model_name, - } - class RawUpnpSensor(UpnpSensor): """Representation of a UPnP/IGD sensor.""" @property - def state(self) -> str | None: + 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] @@ -192,24 +141,18 @@ class RawUpnpSensor(UpnpSensor): class DerivedUpnpSensor(UpnpSensor): """Representation of a UNIT Sent/Received per second sensor.""" - def __init__(self, coordinator, device, sensor_type) -> None: + def __init__(self, coordinator: UpnpDataUpdateCoordinator, sensor_type) -> None: """Initialize sensor.""" - super().__init__(coordinator, device, sensor_type) + super().__init__(coordinator, sensor_type) 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 name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.name} {self._sensor_type['derived_name']}" - - @property - def unique_id(self) -> str: - """Return an unique ID.""" - return f"{self._device.udn}_{self._sensor_type['derived_unique_id']}" - - @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._sensor_type["derived_unit"] @@ -218,7 +161,7 @@ class DerivedUpnpSensor(UpnpSensor): return current_value < self._last_value @property - def state(self) -> str | None: + 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"] diff --git a/homeassistant/components/upnp/translations/he.json b/homeassistant/components/upnp/translations/he.json index e9aba0a7a58..6395b5f029f 100644 --- a/homeassistant/components/upnp/translations/he.json +++ b/homeassistant/components/upnp/translations/he.json @@ -4,20 +4,8 @@ "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" }, - "error": { - "many": "", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "flow_title": "{name}", "step": { - "init": { - "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "one": "\u05e8\u05d9\u05e7", - "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", - "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" - }, "user": { "data": { "unique_id": "\u05d4\u05ea\u05e7\u05df", diff --git a/homeassistant/components/upnp/translations/hu.json b/homeassistant/components/upnp/translations/hu.json index 49756babc8b..8ef3ff8dcc0 100644 --- a/homeassistant/components/upnp/translations/hu.json +++ b/homeassistant/components/upnp/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "incomplete_discovery": "Hi\u00e1nyos felfedez\u00e9s", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" }, "error": { @@ -10,8 +11,16 @@ }, "flow_title": "{name}", "step": { + "init": { + "one": "\u00dcres", + "other": "" + }, + "ssdp_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani ezt az UPnP/IGD eszk\u00f6zt?" + }, "user": { "data": { + "scan_interval": "Friss\u00edt\u00e9si intervallum (m\u00e1sodperc, minimum 30)", "unique_id": "Eszk\u00f6z", "usn": "Eszk\u00f6z" } diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 5b31b2e81d0..db06b09ea18 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -50,4 +50,4 @@ class UptimeSensor(SensorEntity): self._attr_name: str = name self._attr_device_class: str = DEVICE_CLASS_TIMESTAMP self._attr_should_poll: bool = False - self._attr_state: str = dt_util.now().isoformat() + self._attr_native_value: str = dt_util.now().isoformat() diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 3dad1b00fff..4eaef45c4d2 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1 +1,115 @@ -"""The uptimerobot component.""" +"""The Uptime Robot integration.""" +from __future__ import annotations + +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import ( + DeviceRegistry, + async_entries_for_config_entry, + async_get_registry, +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Uptime Robot from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + uptime_robot_api = UptimeRobot( + entry.data[CONF_API_KEY], async_get_clientsession(hass) + ) + dev_reg = await async_get_registry(hass) + + hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator( + hass, + config_entry_id=entry.entry_id, + dev_reg=dev_reg, + api=uptime_robot_api, + ) + + await coordinator.async_config_entry_first_refresh() + + 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 + + +class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for Uptime Robot.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + dev_reg: DeviceRegistry, + api: UptimeRobot, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_method=self._async_update_data, + update_interval=COORDINATOR_UPDATE_INTERVAL, + ) + self._config_entry_id = config_entry_id + self._device_registry = dev_reg + self._api = api + + async def _async_update_data(self) -> list[UptimeRobotMonitor] | None: + """Update data.""" + try: + response = await self._api.async_get_monitors() + except UptimeRobotAuthenticationException as exception: + raise ConfigEntryAuthFailed(exception) from exception + except UptimeRobotException as exception: + raise UpdateFailed(exception) from exception + else: + if response.status != API_ATTR_OK: + raise UpdateFailed(response.error.message) + + monitors: list[UptimeRobotMonitor] = response.data + + current_monitors = { + list(device.identifiers)[0][1] + for device in async_entries_for_config_entry( + self._device_registry, self._config_entry_id + ) + } + new_monitors = {str(monitor.id) for monitor in monitors} + if stale_monitors := current_monitors - new_monitors: + for monitor_id in stale_monitors: + if device := self._device_registry.async_get_device( + {(DOMAIN, monitor_id)} + ): + self._device_registry.async_remove_device(device.id) + + # If there are new monitors, we should reload the config entry so we can + # create new devices and entities. + if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._config_entry_id) + ) + return None + + return monitors diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index e1684d64924..ac0dc0c1186 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -1,9 +1,6 @@ """A platform that to monitor Uptime Robot monitors.""" -from datetime import timedelta -import logging +from __future__ import annotations -import async_timeout -from pyuptimerobot import UptimeRobot import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -12,101 +9,61 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN +from .entity import UptimeRobotEntity + +PLATFORM_SCHEMA = cv.deprecated( + vol.All(PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string})) ) -_LOGGER = logging.getLogger(__name__) - -ATTR_TARGET = "target" - -ATTRIBUTION = "Data provided by Uptime Robot" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) - async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None -): - """Set up the Uptime Robot binary_sensors.""" - uptime_robot_api = UptimeRobot() - api_key = config[CONF_API_KEY] - - def api_wrapper(): - return uptime_robot_api.getMonitors(api_key) - - async def async_update_data(): - """Fetch data from API UptimeRobot API.""" - async with async_timeout.timeout(10): - monitors = await hass.async_add_executor_job(api_wrapper) - if not monitors or monitors.get("stat") != "ok": - raise UpdateFailed("Error communicating with Uptime Robot API") - return monitors - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="uptimerobot", - update_method=async_update_data, - update_interval=timedelta(seconds=60), + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Uptime Robot binary_sensor platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) ) - await coordinator.async_refresh() - - if not coordinator.data or coordinator.data.get("stat") != "ok": - _LOGGER.error("Error connecting to Uptime Robot") - raise PlatformNotReady() +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Uptime Robot binary_sensors.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ UptimeRobotBinarySensor( coordinator, BinarySensorEntityDescription( - key=monitor["id"], - name=monitor["friendly_name"], + key=str(monitor.id), + name=monitor.friendly_name, device_class=DEVICE_CLASS_CONNECTIVITY, ), - target=monitor["url"], + monitor=monitor, ) - for monitor in coordinator.data["monitors"] + for monitor in coordinator.data ], ) -class UptimeRobotBinarySensor(BinarySensorEntity, CoordinatorEntity): +class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity): """Representation of a Uptime Robot binary sensor.""" - def __init__( - self, - coordinator: DataUpdateCoordinator, - description: BinarySensorEntityDescription, - target: str, - ) -> None: - """Initialize Uptime Robot the binary sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._target = target - self._attr_extra_state_attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_TARGET: self._target, - } - @property def is_on(self) -> bool: """Return True if the entity is on.""" - if monitor := next( - ( - monitor - for monitor in self.coordinator.data.get("monitors", []) - if monitor["id"] == self.entity_description.key - ), - None, - ): - return monitor["status"] == 2 - return False + return self.monitor_available diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py new file mode 100644 index 00000000000..1e8bec992ad --- /dev/null +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -0,0 +1,131 @@ +"""Config flow for Uptime Robot integration.""" +from __future__ import annotations + +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAccount, + UptimeRobotApiError, + UptimeRobotApiResponse, + UptimeRobotAuthenticationException, + UptimeRobotException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import API_ATTR_OK, DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Uptime Robot.""" + + VERSION = 1 + + async def _validate_input( + self, data: ConfigType + ) -> tuple[dict[str, str], UptimeRobotAccount | None]: + """Validate the user input allows us to connect.""" + errors: dict[str, str] = {} + response: UptimeRobotApiResponse | UptimeRobotApiError | None = None + uptime_robot_api = UptimeRobot( + data[CONF_API_KEY], async_get_clientsession(self.hass) + ) + + try: + response = await uptime_robot_api.async_get_account_details() + except UptimeRobotAuthenticationException as exception: + LOGGER.error(exception) + errors["base"] = "invalid_api_key" + except UptimeRobotException as exception: + LOGGER.error(exception) + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + LOGGER.exception(exception) + errors["base"] = "unknown" + else: + if response.status != API_ATTR_OK: + errors["base"] = "unknown" + LOGGER.error(response.error.message) + + account: UptimeRobotAccount | None = ( + response.data + if response and response.data and response.data.email + else None + ) + + return errors, account + + async def async_step_user(self, user_input: ConfigType | 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, account = await self._validate_input(user_input) + if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=account.email, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Return the reauth confirm step.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA + ) + errors, account = await self._validate_input(user_input) + if account: + if self.context.get("unique_id") and self.context["unique_id"] != str( + account.user_id + ): + errors["base"] = "reauth_failed_matching_account" + else: + existing_entry = await self.async_set_unique_id(str(account.user_id)) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=user_input + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_failed_existing") + + return self.async_show_form( + step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Import a config entry from configuration.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_API_KEY] == import_config[CONF_API_KEY]: + LOGGER.warning( + "Already configured. This YAML configuration has already been imported. Please remove it" + ) + return self.async_abort(reason="already_configured") + + imported_config = {CONF_API_KEY: import_config[CONF_API_KEY]} + + _, account = await self._validate_input(imported_config) + if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=account.email, data=imported_config) + return self.async_abort(reason="unknown") diff --git a/homeassistant/components/uptimerobot/const.py b/homeassistant/components/uptimerobot/const.py new file mode 100644 index 00000000000..7f3655b75cf --- /dev/null +++ b/homeassistant/components/uptimerobot/const.py @@ -0,0 +1,20 @@ +"""Constants for the Uptime Robot integration.""" +from __future__ import annotations + +from datetime import timedelta +from logging import Logger, getLogger +from typing import Final + +LOGGER: Logger = getLogger(__package__) + +# The free plan is limited to 10 requests/minute +COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10) + +DOMAIN: Final = "uptimerobot" +PLATFORMS: Final = ["binary_sensor"] + +ATTRIBUTION: Final = "Data provided by Uptime Robot" + +ATTR_TARGET: Final = "target" + +API_ATTR_OK: Final = "ok" diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py new file mode 100644 index 00000000000..89ff7680eae --- /dev/null +++ b/homeassistant/components/uptimerobot/entity.py @@ -0,0 +1,62 @@ +"""Base UptimeRobot entity.""" +from __future__ import annotations + +from pyuptimerobot import UptimeRobotMonitor + +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTR_TARGET, ATTRIBUTION, DOMAIN + + +class UptimeRobotEntity(CoordinatorEntity): + """Base UptimeRobot entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: EntityDescription, + monitor: UptimeRobotMonitor, + ) -> None: + """Initialize Uptime Robot entities.""" + super().__init__(coordinator) + self.entity_description = description + self._monitor = monitor + self._attr_device_info = { + "identifiers": {(DOMAIN, str(self.monitor.id))}, + "name": self.monitor.friendly_name, + "manufacturer": "Uptime Robot Team", + "entry_type": "service", + "model": self.monitor.type.name, + } + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_TARGET: self.monitor.url, + } + self._attr_unique_id = str(self.monitor.id) + + @property + def _monitors(self) -> list[UptimeRobotMonitor]: + """Return all monitors.""" + return self.coordinator.data or [] + + @property + def monitor(self) -> UptimeRobotMonitor: + """Return the monitor for this entity.""" + return next( + ( + monitor + for monitor in self._monitors + if str(monitor.id) == self.entity_description.key + ), + self._monitor, + ) + + @property + def monitor_available(self) -> bool: + """Returtn if the monitor is available.""" + return bool(self.monitor.status == 2) diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 414defd5571..279bf6eb43e 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -2,7 +2,13 @@ "domain": "uptimerobot", "name": "Uptime Robot", "documentation": "https://www.home-assistant.io/integrations/uptimerobot", - "requirements": ["pyuptimerobot==0.0.5"], - "codeowners": ["@ludeeus"], - "iot_class": "cloud_polling" -} + "requirements": [ + "pyuptimerobot==21.8.2" + ], + "codeowners": [ + "@ludeeus" + ], + "quality_scale": "platinum", + "iot_class": "cloud_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json new file mode 100644 index 00000000000..094130b470d --- /dev/null +++ b/homeassistant/components/uptimerobot/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "description": "You need to supply a read-only API key from Uptime Robot", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "You need to supply a new read-only API key from Uptime Robot", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_failed_matching_account": "The API key you provided does not match the account ID for existing configuration." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/ca.json b/homeassistant/components/uptimerobot/translations/ca.json new file mode 100644 index 00000000000..a3bccb98295 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/ca.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat 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" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_api_key": "Clau API inv\u00e0lida", + "reauth_failed_matching_account": "La clau API proporcionada no correspon amb l'identificador del compte de la configuraci\u00f3 actual.", + "unknown": "Error inesperat" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Clau API" + }, + "description": "Has de proporcionar una nova clau API de nom\u00e9s lectura d'Uptime Robot", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, + "user": { + "data": { + "api_key": "Clau API" + }, + "description": "Has de proporcionar una clau API de nom\u00e9s lectura d'Uptime Robot" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/cs.json b/homeassistant/components/uptimerobot/translations/cs.json new file mode 100644 index 00000000000..09480693834 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/cs.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_failed_existing": "Nepoda\u0159ilo se aktualizovat polo\u017eku konfigurace, odstra\u0148te pros\u00edm integraci a nastavte ji znovu.", + "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", + "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API", + "reauth_failed_matching_account": "Zadan\u00fd kl\u00ed\u010d API neodpov\u00edd\u00e1 ID \u00fa\u010dtu pro st\u00e1vaj\u00edc\u00ed konfiguraci.", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Kl\u00ed\u010d API" + }, + "description": "Je t\u0159eba zadat nov\u00fd kl\u00ed\u010d API \u010dten\u00ed od spole\u010dnosti Uptime Robot.", + "title": "Znovu ov\u011b\u0159it integraci" + }, + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API" + }, + "description": "Mus\u00edte zadat kl\u00ed\u010d API pro \u010dten\u00ed od spole\u010dnosti Uptime Robot." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/de.json b/homeassistant/components/uptimerobot/translations/de.json new file mode 100644 index 00000000000..a25f58dfe0c --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_failed_existing": "Der Konfigurationseintrag konnte nicht aktualisiert werden. Bitte entferne die Integration und richte sie erneut ein.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", + "reauth_failed_matching_account": "Der von dir angegebene API-Schl\u00fcssel stimmt nicht mit der Konto-ID f\u00fcr die vorhandene Konfiguration \u00fcberein.", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-Schl\u00fcssel" + }, + "description": "Du musst einen neuen schreibgesch\u00fctzten API-Schl\u00fcssel von Uptime Robot bereitstellen.", + "title": "Integration erneut authentifizieren" + }, + "user": { + "data": { + "api_key": "API-Schl\u00fcssel" + }, + "description": "Du musst einen schreibgesch\u00fctzten API-Schl\u00fcssel von Uptime Robot bereitstellen." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/en.json b/homeassistant/components/uptimerobot/translations/en.json new file mode 100644 index 00000000000..ae1a8cf5e45 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.", + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key", + "reauth_failed_matching_account": "The API key you provided does not match the account ID for existing configuration.", + "unknown": "Unexpected error" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + }, + "description": "You need to supply a new read-only API key from Uptime Robot", + "title": "Reauthenticate Integration" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "description": "You need to supply a read-only API key from Uptime Robot" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/es-419.json b/homeassistant/components/uptimerobot/translations/es-419.json new file mode 100644 index 00000000000..445247107c0 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/es-419.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/es.json b/homeassistant/components/uptimerobot/translations/es.json new file mode 100644 index 00000000000..d3c7f2b036d --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 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" + }, + "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" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + }, + "description": "Debe 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" + }, + "description": "Debe proporcionar una clave API de solo lectura de robot de tiempo de actividad/funcionamiento" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/et.json b/homeassistant/components/uptimerobot/translations/et.json new file mode 100644 index 00000000000..c679ea3b19b --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/et.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "reauth_failed_existing": "Seadekirjet ei \u00f5nnestunud uuendada, eemalda sidumine ja seadista see uuesti.", + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_api_key": "Vigane API v\u00f5ti", + "reauth_failed_matching_account": "Sisestatud API v\u00f5ti ei vasta olemasoleva konto ID s\u00e4tetele.", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API v\u00f5ti" + }, + "description": "Pead sisestama uue Uptime Roboti kirjutuskaitstud API-v\u00f5tme", + "title": "Taastuvasta sidumine" + }, + "user": { + "data": { + "api_key": "API v\u00f5ti" + }, + "description": "Pead sisestama Uptime Roboti kirjutuskaitstud API-v\u00f5tme" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/fr.json b/homeassistant/components/uptimerobot/translations/fr.json new file mode 100644 index 00000000000..2b4322bb410 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Compte 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.", + "unknown": "Erreur inattendue" + }, + "error": { + "cannot_connect": "Echec de la connexion", + "invalid_api_key": "Cl\u00e9 API non valide", + "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" + }, + "description": "Vous devez fournir une nouvelle cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot" + }, + "user": { + "data": { + "api_key": "Cl\u00e9 API" + }, + "description": "Vous devez fournir une cl\u00e9 API en lecture seule \u00e0 partir d'Uptime Robot" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/he.json b/homeassistant/components/uptimerobot/translations/he.json new file mode 100644 index 00000000000..07de294aea4 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/he.json @@ -0,0 +1,27 @@ +{ + "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", + "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", + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \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": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/hu.json b/homeassistant/components/uptimerobot/translations/hu.json new file mode 100644 index 00000000000..000851093a5 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/hu.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_failed_existing": "Nem siker\u00fclt friss\u00edteni a konfigur\u00e1ci\u00f3s bejegyz\u00e9st. K\u00e9rj\u00fck, t\u00e1vol\u00edtsa el az integr\u00e1ci\u00f3t, \u00e9s \u00e1ll\u00edtsa be \u00fajra.", + "reauth_successful": "Az ism\u00e9telt hiteles\u00edt\u00e9s sikeres volt", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_api_key": "\u00c9rv\u00e9nytelen API-kulcs", + "reauth_failed_matching_account": "A megadott API -kulcs nem egyezik a megl\u00e9v\u0151 konfigur\u00e1ci\u00f3 fi\u00f3kazonos\u00edt\u00f3j\u00e1val.", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API kulcs" + }, + "description": "Meg kell adnia egy \u00faj, csak olvashat\u00f3 API-kulcsot az Uptime Robot-t\u00f3l", + "title": "Integr\u00e1ci\u00f3 \u00fajb\u00f3li hiteles\u00edt\u00e9se" + }, + "user": { + "data": { + "api_key": "API kulcs" + }, + "description": "Meg kell adnia egy csak olvashat\u00f3 API-kulcsot az Uptime Robot-t\u00f3l" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/it.json b/homeassistant/components/uptimerobot/translations/it.json new file mode 100644 index 00000000000..517bbf6463f --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_failed_existing": "Impossibile aggiornare la voce di configurazione, rimuovere l'integrazione e configurarla di nuovo.", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "unknown": "Errore imprevisto" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_api_key": "Chiave API non valida", + "reauth_failed_matching_account": "La chiave API che hai fornito non corrisponde all'ID account per la configurazione esistente.", + "unknown": "Errore imprevisto" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Chiave API" + }, + "description": "Devi fornire una nuova chiave API di sola lettura da Uptime Robot", + "title": "Autenticare nuovamente l'integrazione" + }, + "user": { + "data": { + "api_key": "Chiave API" + }, + "description": "Devi fornire una chiave API di sola lettura da Uptime Robot" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/nl.json b/homeassistant/components/uptimerobot/translations/nl.json new file mode 100644 index 00000000000..7e0ad6a3cd0 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/nl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "reauth_failed_existing": "Kon de config entry niet updaten, gelieve de integratie te verwijderen en het opnieuw op te zetten.", + "reauth_successful": "Herauthenticatie was succesvol", + "unknown": "Onverwachte fout" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_api_key": "Ongeldige API-sleutel", + "reauth_failed_matching_account": "De API sleutel die u heeft opgegeven komt niet overeen met de account ID voor de bestaande configuratie.", + "unknown": "Onverwachte fout" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-sleutel" + }, + "description": "U moet een alleen-lezen API-sleutel van Uptime Robot opgeven", + "title": "Verifieer de integratie opnieuw" + }, + "user": { + "data": { + "api_key": "API-sleutel" + }, + "description": "U moet een alleen-lezen API-sleutel van Uptime Robot opgeven" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/no.json b/homeassistant/components/uptimerobot/translations/no.json new file mode 100644 index 00000000000..8c6351d78c4 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/no.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_failed_existing": "Kunne ikke oppdatere konfigurasjonsoppf\u00f8ringen. Fjern integrasjonen og sett den opp igjen.", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_api_key": "Ugyldig API-n\u00f8kkel", + "reauth_failed_matching_account": "API-n\u00f8kkelen du oppgav, samsvarer ikke med konto-IDen for eksisterende konfigurasjon.", + "unknown": "Uventet feil" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "description": "Du m\u00e5 angi en ny skrivebeskyttet API-n\u00f8kkel fra Uptime Robot", + "title": "Godkjenne integrering p\u00e5 nytt" + }, + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "description": "Du m\u00e5 angi en skrivebeskyttet API-n\u00f8kkel fra Uptime Robot" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/pl.json b/homeassistant/components/uptimerobot/translations/pl.json new file mode 100644 index 00000000000..18c40afec1e --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/pl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_failed_existing": "Nie mo\u017cna zaktualizowa\u0107 wpisu konfiguracji, usu\u0144 integracj\u0119 i skonfiguruj j\u0105 ponownie.", + "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", + "invalid_api_key": "Nieprawid\u0142owy klucz API", + "reauth_failed_matching_account": "Podany klucz API nie jest zgodny z identyfikatorem konta istniej\u0105cej konfiguracji.", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Klucz API" + }, + "description": "Musisz poda\u0107 nowy, tylko do odczytu, klucz API od Uptime Robot", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, + "user": { + "data": { + "api_key": "Klucz API" + }, + "description": "Musisz poda\u0107 klucz API (tylko do odczytu) od Uptime Robot" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/pt.json b/homeassistant/components/uptimerobot/translations/pt.json new file mode 100644 index 00000000000..10c16aafa0f --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/pt.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Erro inesperado" + }, + "error": { + "invalid_api_key": "Chave de API inv\u00e1lida" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/ru.json b/homeassistant/components/uptimerobot/translations/ru.json new file mode 100644 index 00000000000..88da4b3b768 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/ru.json @@ -0,0 +1,31 @@ +{ + "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_failed_existing": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u0443\u0434\u0430\u043b\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0435\u0451 \u0441\u043d\u043e\u0432\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.", + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "reauth_failed_matching_account": "\u0423\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u0443 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0434\u043b\u044f \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0435\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043d\u043e\u0432\u044b\u0439 \u043a\u043b\u044e\u0447 API Uptime Robot \u0441 \u043f\u0440\u0430\u0432\u0430\u043c\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0442\u0435\u043d\u0438\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" + }, + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 API Uptime Robot \u0441 \u043f\u0440\u0430\u0432\u0430\u043c\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0442\u0435\u043d\u0438\u044f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/zh-Hans.json b/homeassistant/components/uptimerobot/translations/zh-Hans.json new file mode 100644 index 00000000000..d680c09e967 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/zh-Hans.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e", + "reauth_failed_existing": "\u65e0\u6cd5\u66f4\u65b0\u914d\u7f6e\u6761\u76ee\uff0c\u8bf7\u5220\u9664\u96c6\u6210\u5e76\u91cd\u65b0\u8bbe\u7f6e\u3002", + "reauth_successful": "\u91cd\u9a8c\u8bc1\u6210\u529f", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_api_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5", + "reauth_failed_matching_account": "\u60a8\u63d0\u4f9b\u7684 API \u5bc6\u94a5\u4e0e\u73b0\u6709\u914d\u7f6e\u7684\u8d26\u53f7 ID \u4e0d\u5339\u914d", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u5bc6\u94a5" + }, + "description": "\u60a8\u9700\u8981\u4ece Uptime Robot \u4e2d\u63d0\u4f9b\u4e00\u4e2a\"\u53ea\u8bfb API \u5bc6\u94a5\"" + }, + "user": { + "data": { + "api_key": "API \u5bc6\u94a5" + }, + "description": "\u60a8\u9700\u8981\u4ece Uptime Robot \u4e2d\u63d0\u4f9b\u4e00\u4e2a\"\u53ea\u8bfb API \u5bc6\u94a5\"" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/zh-Hant.json b/homeassistant/components/uptimerobot/translations/zh-Hant.json new file mode 100644 index 00000000000..73d27aac1db --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_failed_existing": "\u7121\u6cd5\u66f4\u65b0\u8a2d\u5b9a\u5be6\u9ad4\uff0c\u8acb\u79fb\u9664\u6574\u5408\u4e26\u91cd\u65b0\u8a2d\u5b9a\u3002", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "reauth_failed_matching_account": "\u6240\u63d0\u4f9b\u7684\u5bc6\u9470\u8207\u73fe\u6709\u8a2d\u5b9a\u5e33\u865f ID \u4e0d\u7b26\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u5bc6\u9470" + }, + "description": "\u9700\u8981\u63d0\u4f9b\u7531 Uptime Robot \u53d6\u5f97\u4e00\u7d44\u65b0\u7684\u552f\u8b80 API \u5bc6\u9470", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, + "user": { + "data": { + "api_key": "API \u5bc6\u9470" + }, + "description": "\u9700\u8981\u63d0\u4f9b\u7531 Uptime Robot \u53d6\u5f97\u552f\u8b80 API \u5bc6\u9470" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py new file mode 100644 index 00000000000..679f2e1caa2 --- /dev/null +++ b/homeassistant/components/usb/__init__.py @@ -0,0 +1,229 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +import dataclasses +import fnmatch +import logging +import os +import sys + +from serial.tools.list_ports import comports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import system_info +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_usb + +from .const import DOMAIN +from .flow import FlowDispatcher, USBFlow +from .models import USBDevice +from .utils import usb_device_from_port + +_LOGGER = logging.getLogger(__name__) + +REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown + + +def human_readable_device_name( + device: str, + serial_number: str | None, + manufacturer: str | None, + description: str | None, + vid: str | None, + pid: str | None, +) -> str: + """Return a human readable name from USBDevice attributes.""" + device_details = f"{device}, s/n: {serial_number or 'n/a'}" + manufacturer_details = f" - {manufacturer}" if manufacturer else "" + vendor_details = f" - {vid}:{pid}" if vid else "" + full_details = f"{device_details}{manufacturer_details}{vendor_details}" + + if not description: + return full_details + return f"{description[:26]} - {full_details}" + + +def get_serial_by_id(dev_path: str) -> str: + """Return a /dev/serial/by-id match for given device if available.""" + by_id = "/dev/serial/by-id" + if not os.path.isdir(by_id): + return dev_path + + for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + if os.path.realpath(path) == dev_path: + return path + return dev_path + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the USB Discovery integration.""" + usb = await async_get_usb(hass) + usb_discovery = USBDiscovery(hass, FlowDispatcher(hass), usb) + await usb_discovery.async_setup() + hass.data[DOMAIN] = usb_discovery + websocket_api.async_register_command(hass, websocket_usb_scan) + + return True + + +def _fnmatch_lower(name: str | None, pattern: str) -> bool: + """Match a lowercase version of the name.""" + if name is None: + return False + return fnmatch.fnmatch(name.lower(), pattern) + + +class USBDiscovery: + """Manage USB Discovery.""" + + def __init__( + self, + hass: HomeAssistant, + flow_dispatcher: FlowDispatcher, + usb: list[dict[str, str]], + ) -> None: + """Init USB Discovery.""" + self.hass = hass + self.flow_dispatcher = flow_dispatcher + self.usb = usb + self.seen: set[tuple[str, ...]] = set() + self.observer_active = False + self._request_debouncer: Debouncer | None = None + + async def async_setup(self) -> None: + """Set up USB Discovery.""" + await self._async_start_monitor() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) + + async def async_start(self, event: Event) -> None: + """Start USB Discovery and run a manual scan.""" + self.flow_dispatcher.async_start() + await self._async_scan_serial() + + async def _async_start_monitor(self) -> None: + """Start monitoring hardware with pyudev.""" + if not sys.platform.startswith("linux"): + return + info = await system_info.async_get_system_info(self.hass) + if info.get("docker") and not info.get("hassio"): + return + + from pyudev import ( # pylint: disable=import-outside-toplevel + Context, + Monitor, + MonitorObserver, + ) + + try: + context = Context() + except (ImportError, OSError): + return + + monitor = Monitor.from_netlink(context) + try: + monitor.filter_by(subsystem="tty") + except ValueError as ex: # this fails on WSL + _LOGGER.debug( + "Unable to setup pyudev filtering; This is expected on WSL: %s", ex + ) + return + observer = MonitorObserver( + monitor, callback=self._device_discovered, name="usb-observer" + ) + observer.start() + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: observer.stop() + ) + self.observer_active = True + + def _device_discovered(self, device): + """Call when the observer discovers a new usb tty device.""" + if device.action != "add": + return + _LOGGER.debug( + "Discovered Device at path: %s, triggering scan serial", + device.device_path, + ) + self.scan_serial() + + @callback + def _async_process_discovered_usb_device(self, device: USBDevice) -> None: + """Process a USB discovery.""" + _LOGGER.debug("Discovered USB Device: %s", device) + device_tuple = dataclasses.astuple(device) + if device_tuple in self.seen: + return + self.seen.add(device_tuple) + for matcher in self.usb: + if "vid" in matcher and device.vid != matcher["vid"]: + continue + if "pid" in matcher and device.pid != matcher["pid"]: + continue + if "serial_number" in matcher and not _fnmatch_lower( + device.serial_number, matcher["serial_number"] + ): + continue + if "manufacturer" in matcher and not _fnmatch_lower( + device.manufacturer, matcher["manufacturer"] + ): + continue + if "description" in matcher and not _fnmatch_lower( + device.description, matcher["description"] + ): + continue + flow: USBFlow = { + "domain": matcher["domain"], + "context": {"source": config_entries.SOURCE_USB}, + "data": dataclasses.asdict(device), + } + self.flow_dispatcher.async_create(flow) + + @callback + def _async_process_ports(self, ports: list[ListPortInfo]) -> None: + """Process each discovered port.""" + for port in ports: + if port.vid is None and port.pid is None: + continue + self._async_process_discovered_usb_device(usb_device_from_port(port)) + + def scan_serial(self) -> None: + """Scan serial ports.""" + self.hass.add_job(self._async_process_ports, comports()) + + async def _async_scan_serial(self) -> None: + """Scan serial ports.""" + self._async_process_ports(await self.hass.async_add_executor_job(comports)) + + async def async_request_scan_serial(self) -> None: + """Request a serial scan.""" + if not self._request_debouncer: + self._request_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=REQUEST_SCAN_COOLDOWN, + immediate=True, + function=self._async_scan_serial, + ) + await self._request_debouncer.async_call() + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "usb/scan"}) +@websocket_api.async_response +async def websocket_usb_scan( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, +) -> None: + """Scan for new usb devices.""" + usb_discovery: USBDiscovery = hass.data[DOMAIN] + if not usb_discovery.observer_active: + await usb_discovery.async_request_scan_serial() + connection.send_result(msg["id"]) diff --git a/homeassistant/components/usb/const.py b/homeassistant/components/usb/const.py new file mode 100644 index 00000000000..c31178bc323 --- /dev/null +++ b/homeassistant/components/usb/const.py @@ -0,0 +1,3 @@ +"""Constants for the USB Discovery integration.""" + +DOMAIN = "usb" diff --git a/homeassistant/components/usb/flow.py b/homeassistant/components/usb/flow.py new file mode 100644 index 00000000000..00c40add92a --- /dev/null +++ b/homeassistant/components/usb/flow.py @@ -0,0 +1,48 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +from collections.abc import Coroutine +from typing import Any, TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult + + +class USBFlow(TypedDict): + """A queued usb discovery flow.""" + + domain: str + context: dict[str, Any] + data: dict + + +class FlowDispatcher: + """Dispatch discovery flows.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the discovery dispatcher.""" + self.hass = hass + self.pending_flows: list[USBFlow] = [] + self.started = False + + @callback + def async_start(self, *_: Any) -> None: + """Start processing pending flows.""" + self.started = True + for flow in self.pending_flows: + self.hass.async_create_task(self._init_flow(flow)) + self.pending_flows = [] + + @callback + def async_create(self, flow: USBFlow) -> None: + """Create and add or queue a flow.""" + if self.started: + self.hass.async_create_task(self._init_flow(flow)) + else: + self.pending_flows.append(flow) + + def _init_flow(self, flow: USBFlow) -> Coroutine[None, None, FlowResult]: + """Create a flow.""" + return self.hass.config_entries.flow.async_init( + flow["domain"], context=flow["context"], data=flow["data"] + ) diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json new file mode 100644 index 00000000000..fd22882b8b3 --- /dev/null +++ b/homeassistant/components/usb/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "usb", + "name": "USB Discovery", + "documentation": "https://www.home-assistant.io/integrations/usb", + "requirements": [ + "pyudev==0.22.0", + "pyserial==3.5" + ], + "codeowners": ["@bdraco"], + "dependencies": ["websocket_api"], + "quality_scale": "internal", + "iot_class": "local_push" +} \ No newline at end of file diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py new file mode 100644 index 00000000000..bdc8bc71ced --- /dev/null +++ b/homeassistant/components/usb/models.py @@ -0,0 +1,16 @@ +"""Models helper class for the usb integration.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class USBDevice: + """A usb device.""" + + device: str + vid: str + pid: str + serial_number: str | None + manufacturer: str | None + description: str | None diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py new file mode 100644 index 00000000000..d6bd96882b2 --- /dev/null +++ b/homeassistant/components/usb/utils.py @@ -0,0 +1,18 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +from serial.tools.list_ports_common import ListPortInfo + +from .models import USBDevice + + +def usb_device_from_port(port: ListPortInfo) -> USBDevice: + """Convert serial ListPortInfo to USBDevice.""" + return USBDevice( + device=port.device, + vid=f"{hex(port.vid)[2:]:0>4}".upper(), + pid=f"{hex(port.pid)[2:]:0>4}".upper(), + serial_number=port.serial_number, + manufacturer=port.manufacturer, + description=port.description, + ) diff --git a/homeassistant/components/uscis/sensor.py b/homeassistant/components/uscis/sensor.py index bd261aba4fb..c0c2d1ae165 100644 --- a/homeassistant/components/uscis/sensor.py +++ b/homeassistant/components/uscis/sensor.py @@ -54,7 +54,7 @@ class UscisSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state.""" return self._state diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 25aa6018d44..32ed90a9111 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from croniter import croniter import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -14,6 +15,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ATTR_TARIFF, + CONF_CRON_PATTERN, CONF_METER, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, @@ -40,17 +42,45 @@ ATTR_TARIFFS = "tariffs" DEFAULT_OFFSET = timedelta(hours=0) +def validate_cron_pattern(pattern): + """Check that the pattern is well-formed.""" + if croniter.is_valid(pattern): + return pattern + raise vol.Invalid("Invalid pattern") + + +def period_or_cron(config): + """Check that if cron pattern is used, then meter type and offsite must be removed.""" + if CONF_CRON_PATTERN in config and CONF_METER_TYPE in config: + raise vol.Invalid(f"Use <{CONF_CRON_PATTERN}> or <{CONF_METER_TYPE}>") + if ( + CONF_CRON_PATTERN in config + and CONF_METER_OFFSET in config + and config[CONF_METER_OFFSET] != DEFAULT_OFFSET + ): + raise vol.Invalid( + f"When <{CONF_CRON_PATTERN}> is used <{CONF_METER_OFFSET}> has no meaning" + ) + return config + + METER_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES), - vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, - vol.Optional(CONF_TARIFFS, default=[]): vol.All(cv.ensure_list, [cv.string]), - } + vol.All( + { + vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES), + vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, + vol.Optional(CONF_TARIFFS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_CRON_PATTERN): validate_cron_pattern, + }, + period_or_cron, + ) ) CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 39fd952327b..3be6fa9a061 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -32,9 +32,11 @@ CONF_PAUSED = "paused" CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" CONF_TARIFF_ENTITY = "tariff_entity" +CONF_CRON_PATTERN = "cron" ATTR_TARIFF = "tariff" ATTR_VALUE = "value" +ATTR_CRON_PATTERN = "cron pattern" SIGNAL_START_PAUSE_METER = "utility_meter_start_pause" SIGNAL_RESET_METER = "utility_meter_reset" diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json index 06f2b60297b..a1ba3b6d370 100644 --- a/homeassistant/components/utility_meter/manifest.json +++ b/homeassistant/components/utility_meter/manifest.json @@ -2,6 +2,7 @@ "domain": "utility_meter", "name": "Utility Meter", "documentation": "https://www.home-assistant.io/integrations/utility_meter", + "requirements": ["croniter==1.0.6"], "codeowners": ["@dgomes"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 509c0562f97..ee3fed02a6b 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,13 +1,15 @@ """Utility meter from sensors providing raw data.""" -from datetime import date, timedelta +from datetime import date, datetime, timedelta from decimal import Decimal, DecimalException import logging +from croniter import croniter import voluptuous as vol from homeassistant.components.sensor import ( ATTR_LAST_RESET, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.const import ( @@ -24,6 +26,7 @@ from homeassistant.core import callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( + async_track_point_in_time, async_track_state_change_event, async_track_time_change, ) @@ -31,8 +34,10 @@ from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util from .const import ( + ATTR_CRON_PATTERN, ATTR_VALUE, BIMONTHLY, + CONF_CRON_PATTERN, CONF_METER, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, @@ -90,6 +95,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( CONF_TARIFF_ENTITY ) + conf_cron_pattern = hass.data[DATA_UTILITY][meter].get(CONF_CRON_PATTERN) meters.append( UtilityMeterSensor( @@ -100,6 +106,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= conf_meter_net_consumption, conf.get(CONF_TARIFF), conf_meter_tariff_entity, + conf_cron_pattern, ) ) @@ -126,6 +133,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): net_consumption, tariff=None, tariff_entity=None, + cron_pattern=None, ): """Initialize the Utility Meter sensor.""" self._sensor_source_id = source_entity @@ -140,6 +148,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): self._unit_of_measurement = None self._period = meter_type self._period_offset = meter_offset + self._cron_pattern = cron_pattern self._sensor_net_consumption = net_consumption self._tariff = tariff self._tariff_entity = tariff_entity @@ -206,29 +215,37 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): async def _async_reset_meter(self, event): """Determine cycle - Helper function for larger than daily cycles.""" now = dt_util.now().date() - if ( + if self._cron_pattern is not None: + async_track_point_in_time( + self.hass, + self._async_reset_meter, + croniter(self._cron_pattern, dt_util.now()).get_next(datetime), + ) + elif ( self._period == WEEKLY and now != now - timedelta(days=now.weekday()) + self._period_offset ): return - if ( + elif ( self._period == MONTHLY and now != date(now.year, now.month, 1) + self._period_offset ): return - if ( + elif ( self._period == BIMONTHLY and now != date(now.year, (((now.month - 1) // 2) * 2 + 1), 1) + self._period_offset ): return - if ( + elif ( self._period == QUARTERLY and now != date(now.year, (((now.month - 1) // 3) * 3 + 1), 1) + self._period_offset ): return - if self._period == YEARLY and now != date(now.year, 1, 1) + self._period_offset: + elif ( + self._period == YEARLY and now != date(now.year, 1, 1) + self._period_offset + ): return await self.async_reset_meter(self._tariff_entity) @@ -252,7 +269,13 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): """Handle entity which will be added.""" await super().async_added_to_hass() - if self._period == QUARTER_HOURLY: + if self._cron_pattern is not None: + async_track_point_in_time( + self.hass, + self._async_reset_meter, + croniter(self._cron_pattern, dt_util.now()).get_next(datetime), + ) + elif self._period == QUARTER_HOURLY: for quarter in range(4): async_track_time_change( self.hass, @@ -321,7 +344,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -333,10 +356,14 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): @property def state_class(self): """Return the device class of the sensor.""" - return STATE_CLASS_MEASUREMENT + return ( + STATE_CLASS_MEASUREMENT + if self._sensor_net_consumption + else STATE_CLASS_TOTAL_INCREASING + ) @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement @@ -355,6 +382,8 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): } if self._period is not None: state_attr[ATTR_PERIOD] = self._period + if self._cron_pattern is not None: + state_attr[ATTR_CRON_PATTERN] = self._cron_pattern if self._tariff is not None: state_attr[ATTR_TARIFF] = self._tariff return state_attr diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 6bbd868a8bd..77ff6a30f95 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -131,7 +131,7 @@ class UnifiVideoCamera(Camera): return self._caminfo["recordingSettings"][ "fullTimeRecordEnabled" - ] or recording_state in ["MOTION_INPROGRESS", "MOTION_FINISHED"] + ] or recording_state in ("MOTION_INPROGRESS", "MOTION_FINISHED") @property def motion_detection_enabled(self): @@ -194,10 +194,12 @@ class UnifiVideoCamera(Camera): self._caminfo = caminfo return True - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return the image of this camera.""" if not self._camera and not self._login(): - return + return None def _get_image(retry=True): try: diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index a4df68c3b93..702f3fe7439 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -26,7 +26,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Vacuum devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index 4c1d6e93820..9189568d2f4 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -1,6 +1,8 @@ """Provides device automations for Vacuum.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -31,7 +33,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Vacuum devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] @@ -55,7 +59,9 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: return triggers -async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: """List trigger capabilities.""" return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/vacuum/translations/ar.json b/homeassistant/components/vacuum/translations/ar.json index 630b54d4676..f0148624667 100644 --- a/homeassistant/components/vacuum/translations/ar.json +++ b/homeassistant/components/vacuum/translations/ar.json @@ -2,6 +2,7 @@ "state": { "_": { "cleaning": "\u062a\u0646\u0638\u064a\u0641", + "docked": "\u0631\u0633\u062a", "error": "\u062e\u0637\u0623", "idle": "\u062e\u0627\u0645\u0644", "off": "\u0645\u0637\u0641\u0626", diff --git a/homeassistant/components/vacuum/translations/bn.json b/homeassistant/components/vacuum/translations/bn.json new file mode 100644 index 00000000000..de65f0ce3bd --- /dev/null +++ b/homeassistant/components/vacuum/translations/bn.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u09a1\u0995 \u0995\u09b0\u09be" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/bs.json b/homeassistant/components/vacuum/translations/bs.json new file mode 100644 index 00000000000..58d0dbd19c9 --- /dev/null +++ b/homeassistant/components/vacuum/translations/bs.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Docked" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/cy.json b/homeassistant/components/vacuum/translations/cy.json new file mode 100644 index 00000000000..df39549d654 --- /dev/null +++ b/homeassistant/components/vacuum/translations/cy.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Wedi'i docio" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/en_GB.json b/homeassistant/components/vacuum/translations/en_GB.json new file mode 100644 index 00000000000..58d0dbd19c9 --- /dev/null +++ b/homeassistant/components/vacuum/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Docked" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/eo.json b/homeassistant/components/vacuum/translations/eo.json new file mode 100644 index 00000000000..8d1a74e0b10 --- /dev/null +++ b/homeassistant/components/vacuum/translations/eo.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Aldokita" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/fa.json b/homeassistant/components/vacuum/translations/fa.json index 5e8fb2cae55..753e5974bc9 100644 --- a/homeassistant/components/vacuum/translations/fa.json +++ b/homeassistant/components/vacuum/translations/fa.json @@ -2,6 +2,7 @@ "state": { "_": { "cleaning": "\u062a\u0645\u06cc\u0632 \u06a9\u0631\u062f\u0646", + "docked": "\u0645\u062a\u0635\u0644 \u0634\u062f\u0647 \u0627\u0633\u062a", "off": "\u063a\u06cc\u0631 \u0641\u0639\u0627\u0644", "on": "\u0641\u063a\u0627\u0644", "paused": "\u0645\u06a9\u062b" diff --git a/homeassistant/components/vacuum/translations/gl.json b/homeassistant/components/vacuum/translations/gl.json new file mode 100644 index 00000000000..c0552316a48 --- /dev/null +++ b/homeassistant/components/vacuum/translations/gl.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Acoplado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/ja.json b/homeassistant/components/vacuum/translations/ja.json new file mode 100644 index 00000000000..ba421a8767c --- /dev/null +++ b/homeassistant/components/vacuum/translations/ja.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u30c9\u30c3\u30ad\u30f3\u30b0" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/ka.json b/homeassistant/components/vacuum/translations/ka.json new file mode 100644 index 00000000000..617e001ca45 --- /dev/null +++ b/homeassistant/components/vacuum/translations/ka.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u10d3\u10dd\u10d9\u10d8\u10e0\u10d4\u10d1\u10e3\u10da\u10d8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/lt.json b/homeassistant/components/vacuum/translations/lt.json new file mode 100644 index 00000000000..3cfb5717736 --- /dev/null +++ b/homeassistant/components/vacuum/translations/lt.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "Prikabinta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/pt-BR.json b/homeassistant/components/vacuum/translations/pt-BR.json index 4e6f8c84748..b516880d5df 100644 --- a/homeassistant/components/vacuum/translations/pt-BR.json +++ b/homeassistant/components/vacuum/translations/pt-BR.json @@ -1,8 +1,13 @@ { + "device_automation": { + "condition_type": { + "is_docked": "{entity_name} est\u00e1 na base" + } + }, "state": { "_": { "cleaning": "Limpando", - "docked": "Baseado", + "docked": "Na base", "error": "Erro", "idle": "Em espera", "off": "Desligado", diff --git a/homeassistant/components/vacuum/translations/sr-Latn.json b/homeassistant/components/vacuum/translations/sr-Latn.json new file mode 100644 index 00000000000..8e42ab6fa76 --- /dev/null +++ b/homeassistant/components/vacuum/translations/sr-Latn.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u0414\u043e\u0446\u043a\u0435\u0434" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/sr.json b/homeassistant/components/vacuum/translations/sr.json new file mode 100644 index 00000000000..8e42ab6fa76 --- /dev/null +++ b/homeassistant/components/vacuum/translations/sr.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u0414\u043e\u0446\u043a\u0435\u0434" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/ta.json b/homeassistant/components/vacuum/translations/ta.json new file mode 100644 index 00000000000..d7db9a6b051 --- /dev/null +++ b/homeassistant/components/vacuum/translations/ta.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u0ba8\u0bb1\u0bc1\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/te.json b/homeassistant/components/vacuum/translations/te.json index 774d37755b4..335e14c1d3b 100644 --- a/homeassistant/components/vacuum/translations/te.json +++ b/homeassistant/components/vacuum/translations/te.json @@ -1,7 +1,8 @@ { "state": { "_": { - "cleaning": "\u0c36\u0c41\u0c2d\u0c4d\u0c30\u0c2a\u0c30\u0c41\u0c1a\u0c41\u0c24\u0c4b\u0c02\u0c26\u0c3f" + "cleaning": "\u0c36\u0c41\u0c2d\u0c4d\u0c30\u0c2a\u0c30\u0c41\u0c1a\u0c41\u0c24\u0c4b\u0c02\u0c26\u0c3f", + "docked": "\u0c21\u0c3e\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c2c\u0c21\u0c3f\u0c02\u0c26\u0c3f" } } } \ No newline at end of file diff --git a/homeassistant/components/vacuum/translations/ur.json b/homeassistant/components/vacuum/translations/ur.json new file mode 100644 index 00000000000..410be58bdef --- /dev/null +++ b/homeassistant/components/vacuum/translations/ur.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "docked": "\u0688\u0627\u06a9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 27210e0c750..6f88afa66cf 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -40,7 +40,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -# pylint: disable=no-member PROFILE_TO_STR_SETTABLE = { VALLOX_PROFILE.HOME: "Home", VALLOX_PROFILE.AWAY: "Away", @@ -50,7 +49,6 @@ PROFILE_TO_STR_SETTABLE = { STR_TO_PROFILE = {v: k for (k, v) in PROFILE_TO_STR_SETTABLE.items()} -# pylint: disable=no-member PROFILE_TO_STR_REPORTABLE = { **{VALLOX_PROFILE.NONE: "None", VALLOX_PROFILE.EXTRA: "Extra"}, **PROFILE_TO_STR_SETTABLE, diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 14845f97c1c..b536270c336 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -2,7 +2,7 @@ "domain": "vallox", "name": "Vallox", "documentation": "https://www.home-assistant.io/integrations/vallox", - "requirements": ["vallox-websocket-api==2.4.0"], + "requirements": ["vallox-websocket-api==2.8.1"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index ddfb9d1a7d3..4e4dc6cdddf 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -1,9 +1,15 @@ """Support for Vallox ventilation unit sensors.""" +from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timedelta import logging -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2, @@ -16,154 +22,30 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN, METRIC_KEY_MODE, SIGNAL_VALLOX_STATE_UPDATE +from . import DOMAIN, METRIC_KEY_MODE, SIGNAL_VALLOX_STATE_UPDATE, ValloxStateProxy _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the sensors.""" - if discovery_info is None: - return - - name = hass.data[DOMAIN]["name"] - state_proxy = hass.data[DOMAIN]["state_proxy"] - - sensors = [ - ValloxProfileSensor( - name=f"{name} Current Profile", - state_proxy=state_proxy, - device_class=None, - unit_of_measurement=None, - icon="mdi:gauge", - ), - ValloxFanSpeedSensor( - name=f"{name} Fan Speed", - state_proxy=state_proxy, - metric_key="A_CYC_FAN_SPEED", - device_class=None, - unit_of_measurement=PERCENTAGE, - icon="mdi:fan", - ), - ValloxSensor( - name=f"{name} Extract Air", - state_proxy=state_proxy, - metric_key="A_CYC_TEMP_EXTRACT_AIR", - device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, - icon=None, - ), - ValloxSensor( - name=f"{name} Exhaust Air", - state_proxy=state_proxy, - metric_key="A_CYC_TEMP_EXHAUST_AIR", - device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, - icon=None, - ), - ValloxSensor( - name=f"{name} Outdoor Air", - state_proxy=state_proxy, - metric_key="A_CYC_TEMP_OUTDOOR_AIR", - device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, - icon=None, - ), - ValloxSensor( - name=f"{name} Supply Air", - state_proxy=state_proxy, - metric_key="A_CYC_TEMP_SUPPLY_AIR", - device_class=DEVICE_CLASS_TEMPERATURE, - unit_of_measurement=TEMP_CELSIUS, - icon=None, - ), - ValloxSensor( - name=f"{name} Humidity", - state_proxy=state_proxy, - metric_key="A_CYC_RH_VALUE", - device_class=DEVICE_CLASS_HUMIDITY, - unit_of_measurement=PERCENTAGE, - icon=None, - ), - ValloxFilterRemainingSensor( - name=f"{name} Remaining Time For Filter", - state_proxy=state_proxy, - metric_key="A_CYC_REMAINING_TIME_FOR_FILTER", - device_class=DEVICE_CLASS_TIMESTAMP, - unit_of_measurement=None, - icon="mdi:filter", - ), - ValloxSensor( - name=f"{name} Efficiency", - state_proxy=state_proxy, - metric_key="A_CYC_EXTRACT_EFFICIENCY", - device_class=None, - unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", - ), - ValloxSensor( - name=f"{name} CO2", - state_proxy=state_proxy, - metric_key="A_CYC_CO2_VALUE", - device_class=DEVICE_CLASS_CO2, - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - icon=None, - ), - ] - - async_add_entities(sensors, update_before_add=False) - - class ValloxSensor(SensorEntity): """Representation of a Vallox sensor.""" + _attr_should_poll = False + entity_description: ValloxSensorEntityDescription + def __init__( - self, name, state_proxy, metric_key, device_class, unit_of_measurement, icon + self, + name: str, + state_proxy: ValloxStateProxy, + description: ValloxSensorEntityDescription, ) -> None: """Initialize the Vallox sensor.""" - self._name = name self._state_proxy = state_proxy - self._metric_key = metric_key - self._device_class = device_class - self._unit_of_measurement = unit_of_measurement - self._icon = icon - self._available = None - self._state = None - @property - def should_poll(self): - """Do not poll the device.""" - return False + self.entity_description = description - @property - def name(self): - """Return the name.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def state(self): - """Return the state.""" - return self._state + self._attr_name = f"{name} {description.name}" + self._attr_available = False async def async_added_to_hass(self): """Call to update.""" @@ -181,11 +63,27 @@ class ValloxSensor(SensorEntity): async def async_update(self): """Fetch state from the ventilation unit.""" try: - self._state = self._state_proxy.fetch_metric(self._metric_key) - self._available = True + self._attr_native_value = self._state_proxy.fetch_metric( + self.entity_description.metric_key + ) + self._attr_available = True except (OSError, KeyError) as err: - self._available = False + self._attr_available = False + _LOGGER.error("Error updating sensor: %s", err) + + +class ValloxProfileSensor(ValloxSensor): + """Child class for profile reporting.""" + + async def async_update(self): + """Fetch state from the ventilation unit.""" + try: + self._attr_native_value = self._state_proxy.get_profile() + self._attr_available = True + + except OSError as err: + self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) @@ -207,33 +105,11 @@ class ValloxFanSpeedSensor(ValloxSensor): await super().async_update() else: # Report zero percent otherwise. - self._state = 0 - self._available = True + self._attr_native_value = 0 + self._attr_available = True except (OSError, KeyError) as err: - self._available = False - _LOGGER.error("Error updating sensor: %s", err) - - -class ValloxProfileSensor(ValloxSensor): - """Child class for profile reporting.""" - - def __init__( - self, name, state_proxy, device_class, unit_of_measurement, icon - ) -> None: - """Initialize the Vallox sensor.""" - super().__init__( - name, state_proxy, None, device_class, unit_of_measurement, icon - ) - - async def async_update(self): - """Fetch state from the ventilation unit.""" - try: - self._state = self._state_proxy.get_profile() - self._available = True - - except OSError as err: - self._available = False + self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) @@ -243,16 +119,125 @@ class ValloxFilterRemainingSensor(ValloxSensor): async def async_update(self): """Fetch state from the ventilation unit.""" try: - days_remaining = int(self._state_proxy.fetch_metric(self._metric_key)) + days_remaining = int( + self._state_proxy.fetch_metric(self.entity_description.metric_key) + ) days_remaining_delta = timedelta(days=days_remaining) # 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) - self._state = (now + days_remaining_delta).isoformat() - self._available = True + self._attr_native_value = (now + days_remaining_delta).isoformat() + self._attr_available = True except (OSError, KeyError) as err: - self._available = False + self._attr_available = False _LOGGER.error("Error updating sensor: %s", err) + + +@dataclass +class ValloxSensorEntityDescription(SensorEntityDescription): + """Describes Vallox sensor entity.""" + + metric_key: str | None = None + sensor_type: type[ValloxSensor] = ValloxSensor + + +SENSORS: tuple[ValloxSensorEntityDescription, ...] = ( + ValloxSensorEntityDescription( + key="current_profile", + name="Current Profile", + icon="mdi:gauge", + sensor_type=ValloxProfileSensor, + ), + ValloxSensorEntityDescription( + key="fan_speed", + name="Fan Speed", + metric_key="A_CYC_FAN_SPEED", + icon="mdi:fan", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + sensor_type=ValloxFanSpeedSensor, + ), + ValloxSensorEntityDescription( + key="remaining_time_for_filter", + name="Remaining Time For Filter", + metric_key="A_CYC_REMAINING_TIME_FOR_FILTER", + device_class=DEVICE_CLASS_TIMESTAMP, + sensor_type=ValloxFilterRemainingSensor, + ), + ValloxSensorEntityDescription( + key="extract_air", + name="Extract Air", + metric_key="A_CYC_TEMP_EXTRACT_AIR", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + ValloxSensorEntityDescription( + key="exhaust_air", + name="Exhaust Air", + metric_key="A_CYC_TEMP_EXHAUST_AIR", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + ValloxSensorEntityDescription( + key="outdoor_air", + name="Outdoor Air", + metric_key="A_CYC_TEMP_OUTDOOR_AIR", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + ValloxSensorEntityDescription( + key="supply_air", + name="Supply Air", + metric_key="A_CYC_TEMP_SUPPLY_AIR", + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + ValloxSensorEntityDescription( + key="humidity", + name="Humidity", + metric_key="A_CYC_RH_VALUE", + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + ValloxSensorEntityDescription( + key="efficiency", + name="Efficiency", + metric_key="A_CYC_EXTRACT_EFFICIENCY", + icon="mdi:gauge", + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + ValloxSensorEntityDescription( + key="co2", + name="CO2", + metric_key="A_CYC_CO2_VALUE", + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the sensors.""" + if discovery_info is None: + return + + name = hass.data[DOMAIN]["name"] + state_proxy = hass.data[DOMAIN]["state_proxy"] + + async_add_entities( + [ + description.sensor_type(name, state_proxy, description) + for description in SENSORS + ], + update_before_add=False, + ) diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 31c5da097ff..4c1c1de5e52 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -110,7 +110,7 @@ class VasttrafikDepartureSensor(SensorEntity): return self._attributes @property - def state(self): + def native_value(self): """Return the next departure time.""" return self._state diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index a10d59bad4a..93dd68c9eea 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -1,4 +1,6 @@ """Config flow for the Velbus platform.""" +from __future__ import annotations + import velbus import voluptuous as vol @@ -25,7 +27,7 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the velbus config flow.""" - self._errors = {} + self._errors: dict[str, str] = {} def _create_device(self, name: str, prt: str): """Create an entry async.""" diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 9d9b68dd4eb..3a4aa2302f6 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -45,14 +45,14 @@ class VelbusSensor(VelbusEntity, SensorEntity): return self._module.get_class(self._channel) @property - def state(self): + 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) @property - def unit_of_measurement(self): + 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) diff --git a/homeassistant/components/velbus/translations/hu.json b/homeassistant/components/velbus/translations/hu.json index 414ee7e60c6..6bf3ba689f3 100644 --- a/homeassistant/components/velbus/translations/hu.json +++ b/homeassistant/components/velbus/translations/hu.json @@ -6,6 +6,15 @@ "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "user": { + "data": { + "name": "Ennek a velbus kapcsolatnak a neve", + "port": "Kapcsolati karakterl\u00e1nc" + }, + "title": "Hat\u00e1rozza meg a velbus kapcsolat t\u00edpus\u00e1t" + } } } } \ No newline at end of file diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index feac63f694b..9a153841718 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -25,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType from homeassistant.util import convert, slugify from homeassistant.util.dt import utc_from_timestamp @@ -63,7 +64,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, base_config: dict) -> bool: +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: """Set up for Vera controllers.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 878f6ff376d..dd6d891c11d 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -53,12 +53,12 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) @property - def state(self) -> str: + def native_value(self) -> str: """Return the name of the sensor.""" return self.current_value @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: diff --git a/homeassistant/components/vera/translations/hu.json b/homeassistant/components/vera/translations/hu.json new file mode 100644 index 00000000000..1f1e22b9ed8 --- /dev/null +++ b/homeassistant/components/vera/translations/hu.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "cannot_connect": "Nem siker\u00fclt csatlakozni a {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.", + "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.", + "title": "Vera vez\u00e9rl\u0151 be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "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." + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index a137f61d98f..455d7070a8b 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -79,7 +79,9 @@ class VerisureSmartcam(CoordinatorEntity, Camera): "via_device": (DOMAIN, self.coordinator.entry.data[CONF_GIID]), } - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" self.check_imagelist() if not self._image: diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index d39c235e9d5..cdeddd8d6e4 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -51,7 +51,7 @@ class VerisureThermometer(CoordinatorEntity, SensorEntity): coordinator: VerisureDataUpdateCoordinator _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str @@ -84,7 +84,7 @@ class VerisureThermometer(CoordinatorEntity, SensorEntity): } @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self.coordinator.data["climate"][self.serial_number]["temperature"] @@ -104,7 +104,7 @@ class VerisureHygrometer(CoordinatorEntity, SensorEntity): coordinator: VerisureDataUpdateCoordinator _attr_device_class = DEVICE_CLASS_HUMIDITY - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str @@ -137,7 +137,7 @@ class VerisureHygrometer(CoordinatorEntity, SensorEntity): } @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self.coordinator.data["climate"][self.serial_number]["humidity"] @@ -156,7 +156,7 @@ class VerisureMouseDetection(CoordinatorEntity, SensorEntity): coordinator: VerisureDataUpdateCoordinator - _attr_unit_of_measurement = "Mice" + _attr_native_unit_of_measurement = "Mice" def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str @@ -186,7 +186,7 @@ class VerisureMouseDetection(CoordinatorEntity, SensorEntity): } @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the entity.""" return self.coordinator.data["mice"][self.serial_number]["detections"] diff --git a/homeassistant/components/verisure/translations/zh-Hans.json b/homeassistant/components/verisure/translations/zh-Hans.json new file mode 100644 index 00000000000..e786edb1405 --- /dev/null +++ b/homeassistant/components/verisure/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + } + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/versasense/sensor.py b/homeassistant/components/versasense/sensor.py index d29032af399..50982e92d12 100644 --- a/homeassistant/components/versasense/sensor.py +++ b/homeassistant/components/versasense/sensor.py @@ -65,12 +65,12 @@ class VSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index 04165ec9db1..925e9111c1a 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -2,11 +2,19 @@ from datetime import timedelta import logging -from pyhaversion import HaVersion, HaVersionChannel, HaVersionSource -from pyhaversion.exceptions import HaVersionFetchException, HaVersionParseException +from pyhaversion import ( + HaVersion, + HaVersionChannel, + HaVersionSource, + exceptions as pyhaversionexceptions, +) 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_NAME, CONF_SOURCE from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -30,12 +38,10 @@ ALL_IMAGES = [ "raspberrypi4", "tinker", ] -ALL_SOURCES = [ - "container", - "haio", - "local", - "pypi", - "supervisor", + +HA_VERSION_SOURCES = [source.value for source in HaVersionSource] + +ALL_SOURCES = HA_VERSION_SOURCES + [ "hassio", # Kept to not break existing configurations "docker", # Kept to not break existing configurations ] @@ -48,8 +54,6 @@ DEFAULT_NAME_LATEST = "Latest Version" DEFAULT_NAME_LOCAL = "Current Version" DEFAULT_SOURCE = "local" -ICON = "mdi:package-up" - TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -72,40 +76,42 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= name = config.get(CONF_NAME) source = config.get(CONF_SOURCE) + channel = HaVersionChannel.BETA if beta else HaVersionChannel.STABLE session = async_get_clientsession(hass) - channel = HaVersionChannel.BETA if beta else HaVersionChannel.STABLE + if source in HA_VERSION_SOURCES: + source = HaVersionSource(source) + elif source == "hassio": + source = HaVersionSource.SUPERVISOR + elif source == "docker": + source = HaVersionSource.CONTAINER - if source == "pypi": - haversion = VersionData( - HaVersion(session, source=HaVersionSource.PYPI, channel=channel) - ) - elif source in ["hassio", "supervisor"]: - haversion = VersionData( - HaVersion( - session, source=HaVersionSource.SUPERVISOR, channel=channel, image=image - ) - ) - elif source in ["docker", "container"]: - if image is not None and image != DEFAULT_IMAGE: - image = f"{image}-homeassistant" - haversion = VersionData( - HaVersion( - session, source=HaVersionSource.CONTAINER, channel=channel, image=image - ) - ) - elif source == "haio": - haversion = VersionData(HaVersion(session, source=HaVersionSource.HAIO)) - else: - haversion = VersionData(HaVersion(session, source=HaVersionSource.LOCAL)) + if ( + source == HaVersionSource.CONTAINER + and image is not None + and image != DEFAULT_IMAGE + ): + image = f"{image}-homeassistant" - if not name: - if source == DEFAULT_SOURCE: + if not (name := config.get(CONF_NAME)): + if source == HaVersionSource.LOCAL: name = DEFAULT_NAME_LOCAL else: name = DEFAULT_NAME_LATEST - async_add_entities([VersionSensor(haversion, name)], True) + async_add_entities( + [ + VersionSensor( + VersionData( + HaVersion( + session=session, source=source, image=image, channel=channel + ) + ), + SensorEntityDescription(key=source, name=name), + ) + ], + True, + ) class VersionData: @@ -120,9 +126,9 @@ class VersionData: """Get the latest version information.""" try: await self.api.get_version() - except HaVersionFetchException as exception: + except pyhaversionexceptions.HaVersionFetchException as exception: _LOGGER.warning(exception) - except HaVersionParseException as exception: + except pyhaversionexceptions.HaVersionParseException as exception: _LOGGER.warning( "Could not parse data received for %s - %s", self.api.source, exception ) @@ -131,32 +137,19 @@ class VersionData: class VersionSensor(SensorEntity): """Representation of a Home Assistant version sensor.""" - def __init__(self, data: VersionData, name: str) -> None: + _attr_icon = "mdi:package-up" + + def __init__( + self, + data: VersionData, + description: SensorEntityDescription, + ) -> None: """Initialize the Version sensor.""" self.data = data - self._name = name - self._state = None + self.entity_description = description async def async_update(self): """Get the latest version information.""" await self.data.async_update() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self.data.api.version - - @property - def extra_state_attributes(self): - """Return attributes for the sensor.""" - return self.data.api.version_data - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON + self._attr_native_value = self.data.api.version + self._attr_extra_state_attributes = self.data.api.version_data diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 10821859f9a..ddfbb9f20dd 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -103,7 +103,7 @@ class ViaggiaTrenoSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -113,7 +113,7 @@ class ViaggiaTrenoSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 4f7ab9df985..e96b3b8120a 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -369,12 +369,12 @@ class ViCareSensor(SensorEntity): return self._sensor[CONF_ICON] @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._sensor[CONF_UNIT_OF_MEASUREMENT] diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py index 9483542f19b..acc83b7f115 100644 --- a/homeassistant/components/vilfo/config_flow.py +++ b/homeassistant/components/vilfo/config_flow.py @@ -32,7 +32,7 @@ RESULT_INVALID_AUTH = "invalid_auth" def host_valid(host): """Return True if hostname or IP address is valid.""" try: - if ipaddress.ip_address(host).version in [4, 6]: + if ipaddress.ip_address(host).version in (4, 6): return True except ValueError: disallowed = re.compile(r"[^a-zA-Z\d\-]") diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index 90527c60458..bb2df21f257 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -72,7 +72,7 @@ class VilfoRouterSensor(SensorEntity): return f"{parent_device_name} {sensor_name}" @property - def state(self): + def native_value(self): """Return the state.""" return self._state @@ -82,7 +82,7 @@ class VilfoRouterSensor(SensorEntity): return self._unique_id @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity.""" return SENSOR_TYPES[self.sensor_type].get(ATTR_UNIT) diff --git a/homeassistant/components/vilfo/translations/hu.json b/homeassistant/components/vilfo/translations/hu.json index 34db9cf7cc9..4e2ab47a476 100644 --- a/homeassistant/components/vilfo/translations/hu.json +++ b/homeassistant/components/vilfo/translations/hu.json @@ -14,6 +14,7 @@ "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", "host": "Hoszt" }, + "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/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index 953d64f0ff6..b813d337e82 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -1,4 +1,6 @@ """Support for Vivotek IP Cameras.""" +from __future__ import annotations + from libpyvivotek import VivotekCamera import voluptuous as vol @@ -87,7 +89,9 @@ class VivotekCam(Camera): """Return the interval between frames of the mjpeg stream.""" return self._frame_interval - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" return self._cam.snapshot() diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 0cb2884a8b8..05caae0ec08 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -87,7 +87,7 @@ async def async_setup_entry( ( key for key in config_entry.data.get(CONF_APPS, {}) - if key in [CONF_INCLUDE, CONF_EXCLUDE] + if key in (CONF_INCLUDE, CONF_EXCLUDE) ), None, ) diff --git a/homeassistant/components/vizio/translations/hu.json b/homeassistant/components/vizio/translations/hu.json index 6f0962509f5..edc91cdb31c 100644 --- a/homeassistant/components/vizio/translations/hu.json +++ b/homeassistant/components/vizio/translations/hu.json @@ -6,16 +6,25 @@ "updated_entry": "Ez a bejegyz\u00e9s m\u00e1r be van \u00e1ll\u00edtva, de a konfigur\u00e1ci\u00f3ban defini\u00e1lt n\u00e9v, appok \u00e9s/vagy be\u00e1ll\u00edt\u00e1sok nem egyeznek meg a kor\u00e1bban import\u00e1lt konfigur\u00e1ci\u00f3val, \u00edgy a konfigur\u00e1ci\u00f3s bejegyz\u00e9s ennek megfelel\u0151en friss\u00fclt." }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "complete_pairing_failed": "Nem siker\u00fclt befejezni a p\u00e1ros\u00edt\u00e1st. Az \u00fajb\u00f3li elk\u00fcld\u00e9s el\u0151tt gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a megadott PIN-k\u00f3d helyes, a TV tov\u00e1bbra is be van kapcsolva, \u00e9s csatlakozik a h\u00e1l\u00f3zathoz.", + "existing_config_entry_found": "Egy megl\u00e9v\u0151 VIZIO SmartCast Eszk\u00f6z konfigur\u00e1ci\u00f3s bejegyz\u00e9s ugyanazzal a sorozatsz\u00e1mmal m\u00e1r konfigur\u00e1lva van. Ennek konfigur\u00e1l\u00e1s\u00e1hoz t\u00f6r\u00f6lnie kell a megl\u00e9v\u0151 bejegyz\u00e9st." }, "step": { "pair_tv": { "data": { "pin": "PIN-k\u00f3d" - } + }, + "description": "A TV-nek k\u00f3dot kell megjelen\u00edtenie. \u00cdrja be ezt a k\u00f3dot az \u0171rlapba, majd folytassa a k\u00f6vetkez\u0151 l\u00e9p\u00e9ssel a p\u00e1ros\u00edt\u00e1s befejez\u00e9s\u00e9hez.", + "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 a Home Assistant-hoz.", + "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.", + "title": "P\u00e1ros\u00edt\u00e1s k\u00e9sz" }, "user": { "data": { @@ -24,6 +33,7 @@ "host": "Hoszt", "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.", "title": "VIZIO SmartCast Eszk\u00f6z" } } @@ -32,8 +42,11 @@ "step": { "init": { "data": { + "apps_to_include_or_exclude": "Alkalmaz\u00e1sok felv\u00e9telre vagy kiz\u00e1r\u00e1sra", + "include_or_exclude": "Alkalmaz\u00e1sok felv\u00e9tele vagy kiz\u00e1r\u00e1sa?", "volume_step": "Hanger\u0151 l\u00e9p\u00e9s nagys\u00e1ga" }, + "description": "Ha rendelkezik Smart TV-vel, opcion\u00e1lisan sz\u0171rheti a forr\u00e1slist\u00e1t \u00fagy, hogy kiv\u00e1lasztja, mely alkalmaz\u00e1sokat k\u00edv\u00e1nja felvenni vagy kiz\u00e1rni a forr\u00e1slist\u00e1b\u00f3l.", "title": "VIZIO SmartCast Eszk\u00f6z be\u00e1ll\u00edt\u00e1sok friss\u00edt\u00e9se" } } diff --git a/homeassistant/components/vizio/translations/zh-Hans.json b/homeassistant/components/vizio/translations/zh-Hans.json new file mode 100644 index 00000000000..1fa1ebc751d --- /dev/null +++ b/homeassistant/components/vizio/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "pair_tv": { + "title": "\u5b8c\u6210\u914d\u5bf9\u8fc7\u7a0b" + }, + "pairing_complete": { + "title": "\u914d\u5bf9\u5b8c\u6210" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index 4eb2f512f31..a6f9061319e 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -1,4 +1,6 @@ """Support for consuming values for the Volkszaehler API.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,7 +8,11 @@ from volkszaehler import Volkszaehler from volkszaehler.exceptions import VolkszaehlerApiConnectionError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -30,12 +36,34 @@ DEFAULT_PORT = 80 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) -SENSOR_TYPES = { - "average": ["Average", POWER_WATT, "mdi:power-off"], - "consumption": ["Consumption", ENERGY_WATT_HOUR, "mdi:power-plug"], - "max": ["Max", POWER_WATT, "mdi:arrow-up"], - "min": ["Min", POWER_WATT, "mdi:arrow-down"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="average", + name="Average", + native_unit_of_measurement=POWER_WATT, + icon="mdi:power-off", + ), + SensorEntityDescription( + key="consumption", + name="Consumption", + native_unit_of_measurement=ENERGY_WATT_HOUR, + icon="mdi:power-plug", + ), + SensorEntityDescription( + key="max", + name="Max", + native_unit_of_measurement=POWER_WATT, + icon="mdi:arrow-up", + ), + SensorEntityDescription( + key="min", + name="Min", + native_unit_of_measurement=POWER_WATT, + icon="mdi:arrow-down", + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_MONITORED_CONDITIONS, default=["average"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), } ) @@ -69,54 +97,38 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if vz_api.api.data is None: raise PlatformNotReady - dev = [] - for condition in conditions: - dev.append(VolkszaehlerSensor(vz_api, name, condition)) + entities = [ + VolkszaehlerSensor(vz_api, name, description) + for description in SENSOR_TYPES + if description.key in conditions + ] - async_add_entities(dev, True) + async_add_entities(entities, True) class VolkszaehlerSensor(SensorEntity): """Implementation of a Volkszaehler sensor.""" - def __init__(self, vz_api, name, sensor_type): + def __init__(self, vz_api, name, description: SensorEntityDescription): """Initialize the Volkszaehler sensor.""" + self.entity_description = description self.vz_api = vz_api - self._name = name - self.type = sensor_type - self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {SENSOR_TYPES[self.type][0]}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SENSOR_TYPES[self.type][1] + self._attr_name = f"{name} {description.name}" @property def available(self): """Could the device be accessed during the last update call.""" return self.vz_api.available - @property - def state(self): - """Return the state of the resources.""" - return self._state - async def async_update(self): """Get the latest data from REST API.""" await self.vz_api.async_update() if self.vz_api.api.data is not None: - self._state = round(getattr(self.vz_api.api, self.type), 2) + self._attr_native_value = round( + getattr(self.vz_api.api, self.entity_description.key), 2 + ) class VolkszaehlerData: diff --git a/homeassistant/components/volumio/browse_media.py b/homeassistant/components/volumio/browse_media.py index 41330c37473..25fe929aaf1 100644 --- a/homeassistant/components/volumio/browse_media.py +++ b/homeassistant/components/volumio/browse_media.py @@ -73,9 +73,9 @@ def _item_to_children_media_class(item, info=None): def _item_to_media_class(item, parent_item=None): if "type" not in item: return MEDIA_CLASS_DIRECTORY - if item["type"] in ["webradio", "mywebradio"]: + if item["type"] in ("webradio", "mywebradio"): return MEDIA_CLASS_CHANNEL - if item["type"] in ["song", "cuesong"]: + if item["type"] in ("song", "cuesong"): return MEDIA_CLASS_TRACK if item.get("artist"): return MEDIA_CLASS_ALBUM diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 850f44343c2..86747519149 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -36,8 +36,6 @@ from homeassistant.util import Throttle from .browse_media import browse_node, browse_top_level from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN -_CONFIGURING = {} - SUPPORT_VOLUMIO = ( SUPPORT_PAUSE | SUPPORT_VOLUME_SET @@ -259,7 +257,7 @@ class Volumio(MediaPlayerEntity): async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" self.thumbnail_cache = {} - if media_content_type in [None, "library"]: + if media_content_type in (None, "library"): return await browse_top_level(self._volumio) return await browse_node( diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py index ad6571576b4..7a37713301e 100644 --- a/homeassistant/components/volvooncall/sensor.py +++ b/homeassistant/components/volvooncall/sensor.py @@ -15,11 +15,11 @@ class VolvoSensor(VolvoEntity, SensorEntity): """Representation of a Volvo sensor.""" @property - def state(self): + def native_value(self): """Return the state.""" return self.instrument.state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.instrument.unit diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index 5e6815944d7..01506d4f47e 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -92,12 +92,12 @@ class VultrSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement to present the value in.""" return self._units @property - def state(self): + def native_value(self): """Return the value of this given sensor type.""" try: return round(float(self.data.get(self._condition)), 2) diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 6d3ef952cbe..0691a39ff48 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -1,6 +1,6 @@ """Home Assistant component for accessing the Wallbox Portal API. The sensor component creates multiple sensors regarding wallbox performance.""" -from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -28,7 +28,7 @@ async def async_setup_entry(hass, config, async_add_entities): ) -class WallboxSensor(CoordinatorEntity, Entity): +class WallboxSensor(CoordinatorEntity, SensorEntity): """Representation of the Wallbox portal.""" def __init__(self, coordinator, idx, ent, config): @@ -46,12 +46,12 @@ class WallboxSensor(CoordinatorEntity, Entity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.coordinator.data[self._ent] @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of the sensor.""" return self._unit diff --git a/homeassistant/components/wallbox/translations/zh-Hans.json b/homeassistant/components/wallbox/translations/zh-Hans.json new file mode 100644 index 00000000000..d217ccdc842 --- /dev/null +++ b/homeassistant/components/wallbox/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 084ec17bb28..ed6013daa74 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -129,7 +129,7 @@ class WaqiSensor(SensorEntity): return "mdi:cloud" @property - def state(self): + def native_value(self): """Return the state of the device.""" if self._data is not None: return self._data.get("aqi") @@ -146,7 +146,7 @@ class WaqiSensor(SensorEntity): return self.uid @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return "AQI" diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 3662dee9a5e..dae9e4d579b 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -28,7 +28,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for Water Heater devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 8691cc4ed02..5d7832ca58d 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -101,7 +101,7 @@ class WaterFurnaceSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -111,7 +111,7 @@ class WaterFurnaceSensor(SensorEntity): return self._icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return self._unit_of_measurement diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json index 679ea1ef5c3..cf70a808829 100644 --- a/homeassistant/components/watson_tts/manifest.json +++ b/homeassistant/components/watson_tts/manifest.json @@ -2,7 +2,7 @@ "domain": "watson_tts", "name": "IBM Watson TTS", "documentation": "https://www.home-assistant.io/integrations/watson_tts", - "requirements": ["ibm-watson==5.1.0"], + "requirements": ["ibm-watson==5.2.2"], "codeowners": ["@rutkai"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index cdcbbc6ed2a..610ad61132e 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -191,7 +191,7 @@ class WatsonTTSProvider(Provider): def get_tts_audio(self, message, language=None, options=None): """Request TTS file from Watson TTS.""" response = self.service.synthesize( - message, accept=self.output_format, voice=self.default_voice + text=message, accept=self.output_format, voice=self.default_voice ).get_result() return (CONTENT_TYPE_EXTENSIONS[self.output_format], response.content) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index b0168bbb44e..43265062998 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -202,7 +202,7 @@ class WazeTravelTime(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._waze_data.duration is not None: return round(self._waze_data.duration) @@ -210,7 +210,7 @@ class WazeTravelTime(SensorEntity): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return TIME_MINUTES diff --git a/homeassistant/components/weather/translations/ru.json b/homeassistant/components/weather/translations/ru.json index d2d0a066874..b0f92257631 100644 --- a/homeassistant/components/weather/translations/ru.json +++ b/homeassistant/components/weather/translations/ru.json @@ -1,13 +1,13 @@ { "state": { "_": { - "clear-night": "\u042f\u0441\u043d\u043e, \u043d\u043e\u0447\u044c", + "clear-night": "\u042f\u0441\u043d\u043e", "cloudy": "\u041e\u0431\u043b\u0430\u0447\u043d\u043e", "exceptional": "\u041f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435", "fog": "\u0422\u0443\u043c\u0430\u043d", "hail": "\u0413\u0440\u0430\u0434", - "lightning": "\u041c\u043e\u043b\u043d\u0438\u044f", - "lightning-rainy": "\u041c\u043e\u043b\u043d\u0438\u044f, \u0434\u043e\u0436\u0434\u044c", + "lightning": "\u0413\u0440\u043e\u0437\u0430", + "lightning-rainy": "\u0414\u043e\u0436\u0434\u044c \u0441 \u0433\u0440\u043e\u0437\u043e\u0439", "partlycloudy": "\u041f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0441\u0442\u044c", "pouring": "\u041b\u0438\u0432\u0435\u043d\u044c", "rainy": "\u0414\u043e\u0436\u0434\u044c", diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index d94ab8a7c26..c451645e013 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -127,7 +127,7 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity): self._paused = False self._current_source = None - self._source_list = {} + self._source_list: dict = {} async def async_added_to_hass(self): """Connect and subscribe to dispatcher signals and state updates.""" diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index 60d42e97604..d6f27aff6ae 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -53,12 +53,12 @@ class APICount(SensorEntity): return "Connected clients" @property - def state(self) -> int: + def native_value(self) -> int: """Return current API count.""" return self.count @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return "clients" diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index aa7b5ff05c1..27d3a0cbf25 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -33,9 +33,9 @@ WEMO_MODEL_DISPATCH = { "CoffeeMaker": [SWITCH_DOMAIN], "Dimmer": [LIGHT_DOMAIN], "Humidifier": [FAN_DOMAIN], - "Insight": [SENSOR_DOMAIN, SWITCH_DOMAIN], + "Insight": [BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN], "LightSwitch": [SWITCH_DOMAIN], - "Maker": [SWITCH_DOMAIN], + "Maker": [BINARY_SENSOR_DOMAIN, SWITCH_DOMAIN], "Motion": [BINARY_SENSOR_DOMAIN], "OutdoorPlug": [SWITCH_DOMAIN], "Sensor": [BINARY_SENSOR_DOMAIN], @@ -152,7 +152,7 @@ class WemoDispatcher: if wemo.serialnumber in self._added_serial_numbers: return - device = await async_register_device(hass, self._config_entry, wemo) + coordinator = await async_register_device(hass, self._config_entry, wemo) for component in WEMO_MODEL_DISPATCH.get(wemo.model_name, [SWITCH_DOMAIN]): # Three cases: # - First time we see component, we need to load it and initialize the backlog @@ -160,7 +160,7 @@ class WemoDispatcher: # - Component is loaded, backlog is gone, dispatch discovery if component not in self._loaded_components: - hass.data[DOMAIN]["pending"][component] = [device] + hass.data[DOMAIN]["pending"][component] = [coordinator] self._loaded_components.add(component) hass.async_create_task( hass.config_entries.async_forward_entry_setup( @@ -169,13 +169,13 @@ class WemoDispatcher: ) elif component in hass.data[DOMAIN]["pending"]: - hass.data[DOMAIN]["pending"][component].append(device) + hass.data[DOMAIN]["pending"][component].append(coordinator) else: async_dispatcher_send( hass, f"{DOMAIN}.{component}", - device, + coordinator, ) self._added_serial_numbers.add(wemo.serialnumber) diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index f3ba5e0ec52..a7f1824cf4b 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -2,11 +2,13 @@ import asyncio import logging +from pywemo import Insight, Maker + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity _LOGGER = logging.getLogger(__name__) @@ -14,24 +16,51 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo binary sensors.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoBinarySensor(device)]) + if isinstance(coordinator.wemo, Insight): + async_add_entities([InsightBinarySensor(coordinator)]) + elif isinstance(coordinator.wemo, Maker): + async_add_entities([MakerBinarySensor(coordinator)]) + else: + async_add_entities([WemoBinarySensor(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") ) ) -class WemoBinarySensor(WemoSubscriptionEntity, BinarySensorEntity): +class WemoBinarySensor(WemoEntity, BinarySensorEntity): """Representation a WeMo binary sensor.""" - def _update(self, force_update=True): - """Update the sensor state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) + @property + def is_on(self) -> bool: + """Return true if the state is on. Standby is on.""" + return self.wemo.get_state() + + +class MakerBinarySensor(WemoEntity, BinarySensorEntity): + """Maker device's sensor port.""" + + _name_suffix = "Sensor" + + @property + def is_on(self) -> bool: + """Return true if the Maker's sensor is pulled low.""" + return self.wemo.has_sensor and self.wemo.sensor_state == 0 + + +class InsightBinarySensor(WemoBinarySensor): + """Sensor representing the device connected to the Insight Switch.""" + + _name_suffix = "Device" + + @property + def is_on(self) -> bool: + """Return true device connected to the Insight Switch is on.""" + return super().is_on and self.wemo.insight_params["state"] == "1" diff --git a/homeassistant/components/wemo/const.py b/homeassistant/components/wemo/const.py index 79972affa48..ec59e713b0d 100644 --- a/homeassistant/components/wemo/const.py +++ b/homeassistant/components/wemo/const.py @@ -3,6 +3,5 @@ DOMAIN = "wemo" SERVICE_SET_HUMIDITY = "set_humidity" SERVICE_RESET_FILTER_LIFE = "reset_filter_life" -SIGNAL_WEMO_STATE_PUSH = f"{DOMAIN}.state_push" WEMO_SUBSCRIPTION_EVENT = f"{DOMAIN}_subscription_event" diff --git a/homeassistant/components/wemo/device_trigger.py b/homeassistant/components/wemo/device_trigger.py index ba2ac08ed74..da9a157e1a4 100644 --- a/homeassistant/components/wemo/device_trigger.py +++ b/homeassistant/components/wemo/device_trigger.py @@ -7,7 +7,7 @@ from homeassistant.components.homeassistant.triggers import event as event_trigg from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from .const import DOMAIN as WEMO_DOMAIN, WEMO_SUBSCRIPTION_EVENT -from .wemo_device import async_get_device +from .wemo_device import async_get_coordinator TRIGGER_TYPES = {EVENT_TYPE_LONG_PRESS} @@ -28,11 +28,11 @@ async def async_get_triggers(hass, device_id): CONF_DEVICE_ID: device_id, } - device = async_get_device(hass, device_id) + coordinator = async_get_coordinator(hass, device_id) triggers = [] # Check for long press support. - if device.supports_long_press: + if coordinator.supports_long_press: triggers.append( { # Required fields of TRIGGER_SCHEMA diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 19035367ae5..62b23b78bd7 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -1,136 +1,68 @@ """Classes shared among Wemo entities.""" from __future__ import annotations -import asyncio from collections.abc import Generator import contextlib import logging -import async_timeout -from pywemo import WeMoDevice from pywemo.exceptions import ActionException -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN as WEMO_DOMAIN, SIGNAL_WEMO_STATE_PUSH -from .wemo_device import DeviceWrapper +from .wemo_device import DeviceCoordinator _LOGGER = logging.getLogger(__name__) -class ExceptionHandlerStatus: - """Exit status from the _wemo_exception_handler context manager.""" +class WemoEntity(CoordinatorEntity): + """Common methods for Wemo entities.""" - # An exception if one was raised in the _wemo_exception_handler. - exception: Exception | None = None + # Most pyWeMo devices are associated with a single Home Assistant entity. When + # that is not the case, name_suffix & unique_id_suffix can be used to provide + # names and unique ids for additional Home Assistant entities. + _name_suffix: str | None = None + _unique_id_suffix: str | None = None + + def __init__(self, coordinator: DeviceCoordinator) -> None: + """Initialize the WeMo device.""" + super().__init__(coordinator) + self.wemo = coordinator.wemo + self._device_info = coordinator.device_info + self._available = True @property - def success(self) -> bool: - """Return True if the handler completed with no exception.""" - return self.exception is None - - -class WemoEntity(Entity): - """Common methods for Wemo entities. - - Requires that subclasses implement the _update method. - """ - - def __init__(self, wemo: WeMoDevice) -> None: - """Initialize the WeMo device.""" - self.wemo = wemo - self._state = None - self._available = True - self._update_lock = None - self._has_polled = False + def name_suffix(self): + """Suffix to append to the WeMo device name.""" + return self._name_suffix @property def name(self) -> str: """Return the name of the device if any.""" + suffix = self.name_suffix + if suffix: + return f"{self.wemo.name} {suffix}" return self.wemo.name @property def available(self) -> bool: - """Return true if switch is available.""" - return self._available + """Return true if the device is available.""" + return super().available and self._available - @contextlib.contextmanager - def _wemo_exception_handler( - self, message: str - ) -> Generator[ExceptionHandlerStatus, None, None]: - """Wrap device calls to set `_available` when wemo exceptions happen.""" - status = ExceptionHandlerStatus() - try: - yield status - except ActionException as err: - status.exception = err - _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) - self._available = False - else: - if not self._available: - _LOGGER.info("Reconnected to %s", self.name) - self._available = True - - def _update(self, force_update: bool | None = True): - """Update the device state.""" - raise NotImplementedError() - - async def async_added_to_hass(self) -> None: - """Wemo device added to Home Assistant.""" - # Define inside async context so we know our event loop - self._update_lock = asyncio.Lock() - - async def async_update(self) -> None: - """Update WeMo state. - - Wemo has an aggressive retry logic that sometimes can take over a - minute to return. If we don't get a state within the scan interval, - assume the Wemo switch is unreachable. If update goes through, it will - be made available again. - """ - # If an update is in progress, we don't do anything - if self._update_lock.locked(): - return - - try: - async with async_timeout.timeout( - self.platform.scan_interval.total_seconds() - 0.1 - ) as timeout: - await asyncio.shield(self._async_locked_update(True, timeout)) - except asyncio.TimeoutError: - _LOGGER.warning("Lost connection to %s", self.name) - self._available = False - - async def _async_locked_update( - self, force_update: bool, timeout: async_timeout.timeout | None = None - ) -> None: - """Try updating within an async lock.""" - async with self._update_lock: - await self.hass.async_add_executor_job(self._update, force_update) - self._has_polled = True - # When the timeout expires HomeAssistant is no longer waiting for an - # update from the device. Instead, the state needs to be updated - # asynchronously. This also handles the case where an update came - # directly from the device (device push). In that case no polling - # update was involved and the state also needs to be updated - # asynchronously. - if not timeout or timeout.expired: - self.async_write_ha_state() - - -class WemoSubscriptionEntity(WemoEntity): - """Common methods for Wemo devices that register for update callbacks.""" - - def __init__(self, device: DeviceWrapper) -> None: - """Initialize WemoSubscriptionEntity.""" - super().__init__(device.wemo) - self._device_id = device.device_id - self._device_info = device.device_info + @property + def unique_id_suffix(self): + """Suffix to append to the WeMo device's unique ID.""" + if self._unique_id_suffix is None and self.name_suffix is not None: + return self._name_suffix.lower() + return self._unique_id_suffix @property def unique_id(self) -> str: """Return the id of this WeMo device.""" + suffix = self.unique_id_suffix + if suffix: + return f"{self.wemo.serialnumber}_{suffix}" return self.wemo.serialnumber @property @@ -138,59 +70,17 @@ class WemoSubscriptionEntity(WemoEntity): """Return the device info.""" return self._device_info - @property - def is_on(self) -> bool: - """Return true if the state is on. Standby is on.""" - return self._state + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._available = True + super()._handle_coordinator_update() - @property - def should_poll(self) -> bool: - """Return True if the the device requires local polling, False otherwise. - - It is desirable to allow devices to enter periods of polling when the - callback subscription (device push) is not working. To work with the - entity platform polling logic, this entity needs to report True for - should_poll initially. That is required to cause the entity platform - logic to start the polling task (see the discussion in #47182). - - Polling can be disabled if three conditions are met: - 1. The device has polled to get the initial state (self._has_polled) and - to satisfy the entity platform constraint mentioned above. - 2. The polling was successful and the device is in a healthy state - (self.available). - 3. The pywemo subscription registry reports that there is an active - subscription and the subscription has been confirmed by receiving an - initial event. This confirms that device push notifications are - working correctly (registry.is_subscribed - this method is async safe). - """ - registry = self.hass.data[WEMO_DOMAIN]["registry"] - return not ( - self.available and self._has_polled and registry.is_subscribed(self.wemo) - ) - - async def async_added_to_hass(self) -> None: - """Wemo device added to Home Assistant.""" - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_WEMO_STATE_PUSH, self._async_subscription_callback - ) - ) - - async def _async_subscription_callback( - self, device_id: str, event_type: str, params: str - ) -> None: - """Update the state by the Wemo device.""" - # Only respond events for this device. - if device_id != self._device_id: - return - # If an update is in progress, we don't do anything - if self._update_lock.locked(): - return - - _LOGGER.debug("Subscription event (%s) for %s", event_type, self.name) - updated = await self.hass.async_add_executor_job( - self.wemo.subscription_update, event_type, params - ) - await self._async_locked_update(not updated) + @contextlib.contextmanager + def _wemo_exception_handler(self, message: str) -> Generator[None, None, None]: + """Wrap device calls to set `_available` when wemo exceptions happen.""" + try: + yield + except ActionException as err: + _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) + self._available = False diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 1582a0110cd..501011f841a 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -7,6 +7,7 @@ import math import voluptuous as vol from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity +from homeassistant.core import callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( @@ -20,7 +21,7 @@ from .const import ( SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY, ) -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -68,16 +69,16 @@ SET_HUMIDITY_SCHEMA = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo binary sensors.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoHumidifier(device)]) + async_add_entities([WemoHumidifier(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.fan", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("fan") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("fan") ) ) @@ -94,20 +95,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -class WemoHumidifier(WemoSubscriptionEntity, FanEntity): +class WemoHumidifier(WemoEntity, FanEntity): """Representation of a WeMo humidifier.""" - def __init__(self, device): + def __init__(self, coordinator): """Initialize the WeMo switch.""" - super().__init__(device) - self._fan_mode = WEMO_FAN_OFF - self._fan_mode_string = None - self._target_humidity = None - self._current_humidity = None - self._water_level = None - self._filter_life = None - self._filter_expired = None - self._last_fan_on_mode = WEMO_FAN_MEDIUM + super().__init__(coordinator) + if self.wemo.fan_mode != WEMO_FAN_OFF: + self._last_fan_on_mode = self.wemo.fan_mode + else: + self._last_fan_on_mode = WEMO_FAN_MEDIUM @property def icon(self): @@ -118,18 +115,18 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): def extra_state_attributes(self): """Return device specific state attributes.""" return { - ATTR_CURRENT_HUMIDITY: self._current_humidity, - ATTR_TARGET_HUMIDITY: self._target_humidity, - ATTR_FAN_MODE: self._fan_mode_string, - ATTR_WATER_LEVEL: self._water_level, - ATTR_FILTER_LIFE: self._filter_life, - ATTR_FILTER_EXPIRED: self._filter_expired, + ATTR_CURRENT_HUMIDITY: self.wemo.current_humidity_percent, + ATTR_TARGET_HUMIDITY: self.wemo.desired_humidity_percent, + ATTR_FAN_MODE: self.wemo.fan_mode_string, + ATTR_WATER_LEVEL: self.wemo.water_level_string, + ATTR_FILTER_LIFE: self.wemo.filter_life_percent, + ATTR_FILTER_EXPIRED: self.wemo.filter_expired, } @property def percentage(self) -> int: """Return the current speed percentage.""" - return ranged_value_to_percentage(SPEED_RANGE, self._fan_mode) + return ranged_value_to_percentage(SPEED_RANGE, self.wemo.fan_mode) @property def speed_count(self) -> int: @@ -141,21 +138,17 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): """Flag supported features.""" return SUPPORTED_FEATURES - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.wemo.fan_mode != WEMO_FAN_OFF: + self._last_fan_on_mode = self.wemo.fan_mode + super()._handle_coordinator_update() - self._fan_mode = self.wemo.fan_mode - self._fan_mode_string = self.wemo.fan_mode_string - self._target_humidity = self.wemo.desired_humidity_percent - self._current_humidity = self.wemo.current_humidity_percent - self._water_level = self.wemo.water_level_string - self._filter_life = self.wemo.filter_life_percent - self._filter_expired = self.wemo.filter_expired - - if self.wemo.fan_mode != WEMO_FAN_OFF: - self._last_fan_on_mode = self.wemo.fan_mode + @property + def is_on(self) -> bool: + """Return true if the state is on.""" + return self.wemo.get_state() def turn_on( self, diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 0767c6b6603..13f375ad726 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -1,9 +1,9 @@ """Support for Belkin WeMo lights.""" import asyncio -from datetime import timedelta import logging -from homeassistant import util +from pywemo.ouimeaux_device import bridge + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -15,14 +15,14 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, LightEntity, ) +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoEntity, WemoSubscriptionEntity - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +from .entity import WemoEntity +from .wemo_device import DeviceCoordinator _LOGGER = logging.getLogger(__name__) @@ -31,83 +31,82 @@ SUPPORT_WEMO = ( ) # The WEMO_ constants below come from pywemo itself -WEMO_ON = 1 WEMO_OFF = 0 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo lights.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator: DeviceCoordinator): """Handle a discovered Wemo device.""" - if device.wemo.model_name == "Dimmer": - async_add_entities([WemoDimmer(device)]) + if isinstance(coordinator.wemo, bridge.Bridge): + async_setup_bridge(hass, config_entry, async_add_entities, coordinator) else: - await hass.async_add_executor_job( - setup_bridge, hass, device.wemo, async_add_entities - ) + async_add_entities([WemoDimmer(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.light", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("light") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("light") ) ) -def setup_bridge(hass, bridge, async_add_entities): +@callback +def async_setup_bridge(hass, config_entry, async_add_entities, coordinator): """Set up a WeMo link.""" - lights = {} - - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_lights(): - """Update the WeMo led objects with latest info from the bridge.""" - bridge.bridge_update() + known_light_ids = set() + @callback + def async_update_lights(): + """Check to see if the bridge has any new lights.""" new_lights = [] - for light_id, device in bridge.Lights.items(): - if light_id not in lights: - lights[light_id] = WemoLight(device, update_lights) - new_lights.append(lights[light_id]) + for light_id, light in coordinator.wemo.Lights.items(): + if light_id not in known_light_ids: + known_light_ids.add(light_id) + new_lights.append(WemoLight(coordinator, light)) if new_lights: - hass.add_job(async_add_entities, new_lights) + async_add_entities(new_lights) - update_lights() + async_update_lights() + config_entry.async_on_unload(coordinator.async_add_listener(async_update_lights)) class WemoLight(WemoEntity, LightEntity): """Representation of a WeMo light.""" - def __init__(self, device, update_lights): + def __init__(self, coordinator: DeviceCoordinator, light: bridge.Light) -> None: """Initialize the WeMo light.""" - super().__init__(device) - self._update_lights = update_lights - self._brightness = None - self._hs_color = None - self._color_temp = None - self._is_on = None - self._unique_id = self.wemo.uniqueID - self._model_name = type(self.wemo).__name__ + super().__init__(coordinator) + self.light = light + self._unique_id = self.light.uniqueID + self._model_name = type(self.light).__name__ - async def async_added_to_hass(self): - """Wemo light added to Home Assistant.""" - # Define inside async context so we know our event loop - self._update_lock = asyncio.Lock() + @property + def name(self) -> str: + """Return the name of the device if any.""" + return self.light.name + + @property + def available(self) -> bool: + """Return true if the device is available.""" + return super().available and self.light.state.get("available") @property def unique_id(self): """Return the ID of this light.""" - return self.wemo.uniqueID + return self.light.uniqueID @property def device_info(self): """Return the device info.""" return { "name": self.name, + "connections": {(CONNECTION_ZIGBEE, self._unique_id)}, "identifiers": {(WEMO_DOMAIN, self._unique_id)}, "model": self._model_name, "manufacturer": "Belkin", @@ -116,22 +115,25 @@ class WemoLight(WemoEntity, LightEntity): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness + return self.light.state.get("level", 255) @property def hs_color(self): """Return the hs color values of this light.""" - return self._hs_color + xy_color = self.light.state.get("color_xy") + if xy_color: + return color_util.color_xy_to_hs(*xy_color) + return None @property def color_temp(self): """Return the color temperature of this light in mireds.""" - return self._color_temp + return self.light.state.get("temperature_mireds") @property def is_on(self): """Return true if device is on.""" - return self._is_on + return self.light.state.get("onoff") != WEMO_OFF @property def supported_features(self): @@ -158,13 +160,14 @@ class WemoLight(WemoEntity, LightEntity): with self._wemo_exception_handler("turn on"): if xy_color is not None: - self.wemo.set_color(xy_color, transition=transition_time) + self.light.set_color(xy_color, transition=transition_time) if color_temp is not None: - self.wemo.set_temperature(mireds=color_temp, transition=transition_time) + self.light.set_temperature( + mireds=color_temp, transition=transition_time + ) - if self.wemo.turn_on(**turn_on_kwargs): - self._state["onoff"] = WEMO_ON + self.light.turn_on(**turn_on_kwargs) self.schedule_update_ha_state() @@ -173,37 +176,14 @@ class WemoLight(WemoEntity, LightEntity): transition_time = int(kwargs.get(ATTR_TRANSITION, 0)) with self._wemo_exception_handler("turn off"): - if self.wemo.turn_off(transition=transition_time): - self._state["onoff"] = WEMO_OFF + self.light.turn_off(transition=transition_time) self.schedule_update_ha_state() - def _update(self, force_update=True): - """Synchronize state with bridge.""" - with self._wemo_exception_handler("update status") as handler: - self._update_lights(no_throttle=force_update) - self._state = self.wemo.state - if handler.success: - self._is_on = self._state.get("onoff") != WEMO_OFF - self._brightness = self._state.get("level", 255) - self._color_temp = self._state.get("temperature_mireds") - xy_color = self._state.get("color_xy") - - if xy_color: - self._hs_color = color_util.color_xy_to_hs(*xy_color) - else: - self._hs_color = None - - -class WemoDimmer(WemoSubscriptionEntity, LightEntity): +class WemoDimmer(WemoEntity, LightEntity): """Representation of a WeMo dimmer.""" - def __init__(self, device): - """Initialize the WeMo dimmer.""" - super().__init__(device) - self._brightness = None - @property def supported_features(self): """Flag supported features.""" @@ -212,15 +192,13 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): @property def brightness(self): """Return the brightness of this light between 1 and 100.""" - return self._brightness + wemo_brightness = int(self.wemo.get_brightness()) + return int((wemo_brightness * 255) / 100) - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) - - wemobrightness = int(self.wemo.get_brightness(force_update)) - self._brightness = int((wemobrightness * 255) / 100) + @property + def is_on(self) -> bool: + """Return true if the state is on.""" + return self.wemo.get_state() def turn_on(self, **kwargs): """Turn the dimmer on.""" @@ -229,21 +207,17 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] brightness = int((brightness / 255) * 100) + with self._wemo_exception_handler("set brightness"): + self.wemo.set_brightness(brightness) else: - brightness = 255 - - with self._wemo_exception_handler("turn on"): - if self.wemo.on(): - self._state = WEMO_ON - - self.wemo.set_brightness(brightness) + with self._wemo_exception_handler("turn on"): + self.wemo.on() self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the dimmer off.""" with self._wemo_exception_handler("turn off"): - if self.wemo.off(): - self._state = WEMO_OFF + self.wemo.off() self.schedule_update_ha_state() diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 21a7760741a..59eae24c714 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -3,7 +3,7 @@ "name": "Belkin WeMo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wemo", - "requirements": ["pywemo==0.6.6"], + "requirements": ["pywemo==0.6.7"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index ebd68231e0c..d1a15ecec3a 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -1,10 +1,9 @@ """Support for power sensors in WeMo Insight devices.""" import asyncio -from datetime import datetime, timedelta -from typing import Callable from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, SensorEntityDescription, ) @@ -16,61 +15,44 @@ from homeassistant.const import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import StateType -from homeassistant.util import Throttle, convert, dt +from homeassistant.util import convert from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity -from .wemo_device import DeviceWrapper - -SCAN_INTERVAL = timedelta(seconds=10) +from .entity import WemoEntity +from .wemo_device import DeviceCoordinator async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo sensors.""" - async def _discovered_wemo(device: DeviceWrapper): + async def _discovered_wemo(coordinator: DeviceCoordinator): """Handle a discovered Wemo device.""" - - @Throttle(SCAN_INTERVAL) - def update_insight_params(): - device.wemo.update_insight_params() - async_add_entities( - [ - InsightCurrentPower(device, update_insight_params), - InsightTodayEnergy(device, update_insight_params), - ] + [InsightCurrentPower(coordinator), InsightTodayEnergy(coordinator)] ) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.sensor", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") ) ) -class InsightSensor(WemoSubscriptionEntity, SensorEntity): +class InsightSensor(WemoEntity, SensorEntity): """Common base for WeMo Insight power sensors.""" - _name_suffix: str - - def __init__(self, device: DeviceWrapper, update_insight_params: Callable) -> None: - """Initialize the WeMo Insight power sensor.""" - super().__init__(device) - self._update_insight_params = update_insight_params - @property - def name(self) -> str: + def name_suffix(self) -> str: """Return the name of the entity if any.""" - return f"{super().name} {self.entity_description.name}" + return self.entity_description.name @property - def unique_id(self) -> str: + def unique_id_suffix(self) -> str: """Return the id of this entity.""" - return f"{super().unique_id}_{self.entity_description.key}" + return self.entity_description.key @property def available(self) -> str: @@ -80,11 +62,6 @@ class InsightSensor(WemoSubscriptionEntity, SensorEntity): and super().available ) - def _update(self, force_update=True) -> None: - with self._wemo_exception_handler("update status"): - if force_update or not self.wemo.insight_params: - self._update_insight_params() - class InsightCurrentPower(InsightSensor): """Current instantaineous power consumption.""" @@ -94,11 +71,11 @@ class InsightCurrentPower(InsightSensor): name="Current Power", device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, ) @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the current power consumption.""" return ( convert(self.wemo.insight_params[self.entity_description.key], float, 0.0) @@ -113,17 +90,12 @@ class InsightTodayEnergy(InsightSensor): key="todaymw", name="Today Energy", device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ) @property - def last_reset(self) -> datetime: - """Return the time when the sensor was initialized.""" - return dt.start_of_local_day() - - @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the current energy use today.""" miliwatts = convert( self.wemo.insight_params[self.entity_description.key], float, 0.0 diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index a7031d669a4..46e143902f9 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -3,13 +3,15 @@ import asyncio from datetime import datetime, timedelta import logging +from pywemo import CoffeeMaker, Insight, Maker + from homeassistant.components.switch import SwitchEntity from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import convert from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -33,63 +35,61 @@ WEMO_STANDBY = 8 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo switches.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoSwitch(device)]) + async_add_entities([WemoSwitch(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.switch", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("switch") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("switch") ) ) -class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): +class WemoSwitch(WemoEntity, SwitchEntity): """Representation of a WeMo switch.""" - def __init__(self, device): - """Initialize the WeMo switch.""" - super().__init__(device) - self.insight_params = None - self.maker_params = None - self.coffeemaker_mode = None - self._mode_string = None - @property def extra_state_attributes(self): """Return the state attributes of the device.""" attr = {} - if self.maker_params: + if isinstance(self.wemo, Maker): # Is the maker sensor on or off. - if self.maker_params["hassensor"]: + if self.wemo.maker_params["hassensor"]: # Note a state of 1 matches the WeMo app 'not triggered'! - if self.maker_params["sensorstate"]: + if self.wemo.maker_params["sensorstate"]: attr[ATTR_SENSOR_STATE] = STATE_OFF else: attr[ATTR_SENSOR_STATE] = STATE_ON # Is the maker switch configured as toggle(0) or momentary (1). - if self.maker_params["switchmode"]: + if self.wemo.maker_params["switchmode"]: attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_MOMENTARY else: attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_TOGGLE - if self.insight_params or (self.coffeemaker_mode is not None): + if isinstance(self.wemo, (Insight, CoffeeMaker)): attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state - if self.insight_params: - attr["on_latest_time"] = WemoSwitch.as_uptime(self.insight_params["onfor"]) - attr["on_today_time"] = WemoSwitch.as_uptime(self.insight_params["ontoday"]) - attr["on_total_time"] = WemoSwitch.as_uptime(self.insight_params["ontotal"]) + if isinstance(self.wemo, Insight): + attr["on_latest_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["onfor"] + ) + attr["on_today_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["ontoday"] + ) + attr["on_total_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["ontotal"] + ) attr["power_threshold_w"] = ( - convert(self.insight_params["powerthreshold"], float, 0.0) / 1000.0 + convert(self.wemo.insight_params["powerthreshold"], float, 0.0) / 1000.0 ) - if self.coffeemaker_mode is not None: - attr[ATTR_COFFEMAKER_MODE] = self.coffeemaker_mode + if isinstance(self.wemo, CoffeeMaker): + attr[ATTR_COFFEMAKER_MODE] = self.wemo.mode return attr @@ -104,23 +104,25 @@ class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): @property def current_power_w(self): """Return the current power usage in W.""" - if self.insight_params: - return convert(self.insight_params["currentpower"], float, 0.0) / 1000.0 + if isinstance(self.wemo, Insight): + return ( + convert(self.wemo.insight_params["currentpower"], float, 0.0) / 1000.0 + ) @property def today_energy_kwh(self): """Return the today total energy usage in kWh.""" - if self.insight_params: - miliwatts = convert(self.insight_params["todaymw"], float, 0.0) + if isinstance(self.wemo, Insight): + miliwatts = convert(self.wemo.insight_params["todaymw"], float, 0.0) return round(miliwatts / (1000.0 * 1000.0 * 60), 2) @property def detail_state(self): """Return the state of the device.""" - if self.coffeemaker_mode is not None: - return self._mode_string - if self.insight_params: - standby_state = int(self.insight_params["state"]) + if isinstance(self.wemo, CoffeeMaker): + return self.wemo.mode_string + if isinstance(self.wemo, Insight): + standby_state = int(self.wemo.insight_params["state"]) if standby_state == WEMO_ON: return STATE_ON if standby_state == WEMO_OFF: @@ -132,36 +134,25 @@ class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): @property def icon(self): """Return the icon of device based on its type.""" - if self.wemo.model_name == "CoffeeMaker": + if isinstance(self.wemo, CoffeeMaker): return "mdi:coffee" return None + @property + def is_on(self) -> bool: + """Return true if the state is on. Standby is on.""" + return self.wemo.get_state() + def turn_on(self, **kwargs): """Turn the switch on.""" with self._wemo_exception_handler("turn on"): - if self.wemo.on(): - self._state = WEMO_ON + self.wemo.on() self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the switch off.""" with self._wemo_exception_handler("turn off"): - if self.wemo.off(): - self._state = WEMO_OFF + self.wemo.off() self.schedule_update_ha_state() - - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) - - if self.wemo.model_name == "Insight": - self.insight_params = self.wemo.insight_params - self.insight_params["standby_state"] = self.wemo.get_standby_state - elif self.wemo.model_name == "Maker": - self.maker_params = self.wemo.maker_params - elif self.wemo.model_name == "CoffeeMaker": - self.coffeemaker_mode = self.wemo.mode - self._mode_string = self.wemo.mode_string diff --git a/homeassistant/components/wemo/translations/hu.json b/homeassistant/components/wemo/translations/hu.json index bcb2f438353..ff9f4dc5f75 100644 --- a/homeassistant/components/wemo/translations/hu.json +++ b/homeassistant/components/wemo/translations/hu.json @@ -3,6 +3,11 @@ "abort": { "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Wemo-t?" + } } }, "device_automation": { diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 6fd1f4d5512..1690d30e082 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -1,7 +1,10 @@ """Home Assistant wrapper for a pyWeMo device.""" +import asyncio +from datetime import timedelta import logging -from pywemo import WeMoDevice +from pywemo import Insight, WeMoDevice +from pywemo.exceptions import ActionException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant.config_entries import ConfigEntry @@ -14,28 +17,36 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import async_get as async_get_device_registry -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, SIGNAL_WEMO_STATE_PUSH, WEMO_SUBSCRIPTION_EVENT +from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT _LOGGER = logging.getLogger(__name__) -class DeviceWrapper: +class DeviceCoordinator(DataUpdateCoordinator): """Home Assistant wrapper for a pyWeMo device.""" def __init__(self, hass: HomeAssistant, wemo: WeMoDevice, device_id: str) -> None: - """Initialize DeviceWrapper.""" + """Initialize DeviceCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=wemo.name, + update_interval=timedelta(seconds=30), + ) self.hass = hass self.wemo = wemo self.device_id = device_id self.device_info = _device_info(wemo) self.supports_long_press = wemo.supports_long_press() + self.update_lock = asyncio.Lock() def subscription_callback( self, _device: WeMoDevice, event_type: str, params: str ) -> None: """Receives push notifications from WeMo devices.""" + _LOGGER.debug("Subscription event (%s) for %s", event_type, self.wemo.name) if event_type == EVENT_TYPE_LONG_PRESS: self.hass.bus.fire( WEMO_SUBSCRIPTION_EVENT, @@ -48,9 +59,65 @@ class DeviceWrapper: }, ) else: - dispatcher_send( - self.hass, SIGNAL_WEMO_STATE_PUSH, self.device_id, event_type, params - ) + updated = self.wemo.subscription_update(event_type, params) + self.hass.add_job(self._async_subscription_callback(updated)) + + async def _async_subscription_callback(self, updated: bool) -> None: + """Update the state by the Wemo device.""" + # If an update is in progress, we don't do anything. + if self.update_lock.locked(): + return + try: + await self._async_locked_update(not updated) + except UpdateFailed as err: + self.last_exception = err + if self.last_update_success: + _LOGGER.exception("Subscription callback failed") + self.last_update_success = False + except Exception as err: # pylint: disable=broad-except + self.last_exception = err + self.last_update_success = False + _LOGGER.exception("Unexpected error fetching %s data: %s", self.name, err) + else: + self.async_set_updated_data(None) + + @property + def should_poll(self) -> bool: + """Return True if polling is needed to update the state for the device. + + The alternative, when this returns False, is to rely on the subscription + "push updates" to update the device state in Home Assistant. + """ + if isinstance(self.wemo, Insight) and self.wemo.get_state() == 0: + # The WeMo Insight device does not send subscription updates for the + # insight_params values when the device is off. Polling is required in + # this case so the Sensor entities are properly populated. + return True + + registry = self.hass.data[DOMAIN]["registry"] + return not (registry.is_subscribed(self.wemo) and self.last_update_success) + + async def _async_update_data(self) -> None: + """Update WeMo state.""" + # No need to poll if the device will push updates. + if not self.should_poll: + return + + # If an update is in progress, we don't do anything. + if self.update_lock.locked(): + return + + await self._async_locked_update(True) + + async def _async_locked_update(self, force_update: bool) -> None: + """Try updating within an async lock.""" + async with self.update_lock: + try: + await self.hass.async_add_executor_job( + self.wemo.get_state, force_update + ) + except ActionException as err: + raise UpdateFailed("WeMo update failed") from err def _device_info(wemo: WeMoDevice): @@ -64,19 +131,21 @@ def _device_info(wemo: WeMoDevice): async def async_register_device( hass: HomeAssistant, config_entry: ConfigEntry, wemo: WeMoDevice -) -> DeviceWrapper: +) -> DeviceCoordinator: """Register a device with home assistant and enable pywemo event callbacks.""" + # Ensure proper communication with the device and get the initial state. + await hass.async_add_executor_job(wemo.get_state, True) + device_registry = async_get_device_registry(hass) entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, **_device_info(wemo) ) - registry = hass.data[DOMAIN]["registry"] - await hass.async_add_executor_job(registry.register, wemo) - - device = DeviceWrapper(hass, wemo, entry.id) + device = DeviceCoordinator(hass, wemo, entry.id) hass.data[DOMAIN].setdefault("devices", {})[entry.id] = device + registry = hass.data[DOMAIN]["registry"] registry.on(wemo, None, device.subscription_callback) + await hass.async_add_executor_job(registry.register, wemo) if device.supports_long_press: try: @@ -93,6 +162,6 @@ async def async_register_device( @callback -def async_get_device(hass: HomeAssistant, device_id: str) -> DeviceWrapper: - """Return DeviceWrapper for device_id.""" +def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordinator: + """Return DeviceCoordinator for device_id.""" return hass.data[DOMAIN]["devices"][device_id] diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 4219c80193d..5d5e595fa50 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -70,12 +70,12 @@ class WhoisSensor(SensorEntity): return "mdi:calendar-clock" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement to present the value in.""" return TIME_DAYS @property - def state(self): + def native_value(self): """Return the expiration days for hostname.""" return self._state diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 800a420f8f0..b9bcd317b46 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -78,12 +78,12 @@ class NumberEntity(WiffiEntity, SensorEntity): return self._device_class @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity.""" return self._unit_of_measurement @property - def state(self): + def native_value(self): """Return the value of the entity.""" return self._value @@ -111,7 +111,7 @@ class StringEntity(WiffiEntity, SensorEntity): self.reset_expiration_date() @property - def state(self): + def native_value(self): """Return the value of the entity.""" return self._value diff --git a/homeassistant/components/wiffi/translations/hu.json b/homeassistant/components/wiffi/translations/hu.json index c623f6ddaba..902fabcbc85 100644 --- a/homeassistant/components/wiffi/translations/hu.json +++ b/homeassistant/components/wiffi/translations/hu.json @@ -1,13 +1,15 @@ { "config": { "abort": { + "addr_in_use": "A szerverport m\u00e1r haszn\u00e1latban van.", "start_server_failed": "A szerver ind\u00edt\u00e1sa nem siker\u00fclt." }, "step": { "user": { "data": { "port": "Port" - } + }, + "title": "TCP szerver be\u00e1ll\u00edt\u00e1sa WIFFI eszk\u00f6z\u00f6kh\u00f6z" } } }, diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index f11e15670e9..702851a5e14 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -1,9 +1,12 @@ """Support for Wink hubs.""" +from __future__ import annotations + from datetime import timedelta import json import logging import os import time +from typing import Any from aiohttp.web import Response from pubnubsubhandler import PubNubSubscriptionHandler @@ -111,25 +114,28 @@ CHIME_TONES = TONES + ["inactive"] AUTO_SHUTOFF_TIMES = [None, -1, 30, 60, 120] CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Inclusive( - CONF_EMAIL, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG - ): cv.string, - vol.Inclusive( - CONF_PASSWORD, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG - ): cv.string, - vol.Inclusive( - CONF_CLIENT_ID, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG - ): cv.string, - vol.Inclusive( - CONF_CLIENT_SECRET, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG - ): cv.string, - vol.Optional(CONF_LOCAL_CONTROL, default=False): cv.boolean, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Inclusive( + CONF_EMAIL, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG + ): cv.string, + vol.Inclusive( + CONF_PASSWORD, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG + ): cv.string, + vol.Inclusive( + CONF_CLIENT_ID, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG + ): cv.string, + vol.Inclusive( + CONF_CLIENT_SECRET, CONF_OAUTH, msg=CONF_MISSING_OAUTH_MSG + ): cv.string, + vol.Optional(CONF_LOCAL_CONTROL, default=False): cv.boolean, + } + ), + }, + ), extra=vol.ALLOW_EXTRA, ) @@ -205,7 +211,7 @@ WINK_COMPONENTS = [ "water_heater", ] -WINK_HUBS = [] +WINK_HUBS: list[Any] = [] def _request_app_setup(hass, config): @@ -282,6 +288,10 @@ def _request_oauth_completion(hass, config): def setup(hass, config): # noqa: C901 """Set up the Wink component.""" + _LOGGER.warning( + "The Wink integration has been deprecated and is pending removal in " + "Home Assistant Core 2021.11" + ) if hass.data.get(DOMAIN) is None: hass.data[DOMAIN] = { diff --git a/homeassistant/components/wink/climate.py b/homeassistant/components/wink/climate.py index 4c783e6bde1..7836d71614f 100644 --- a/homeassistant/components/wink/climate.py +++ b/homeassistant/components/wink/climate.py @@ -234,7 +234,7 @@ class WinkThermostat(WinkDevice, ClimateEntity): return HVAC_MODE_HEAT if wink_mode == "eco": return HVAC_MODE_AUTO - return WINK_HVAC_TO_HA.get(wink_mode) + return WINK_HVAC_TO_HA.get(wink_mode, "") @property def hvac_modes(self): @@ -437,7 +437,7 @@ class WinkAC(WinkDevice, ClimateEntity): wink_mode = self.wink.current_mode() if wink_mode == "auto_eco": return HVAC_MODE_COOL - return WINK_HVAC_TO_HA.get(wink_mode) + return WINK_HVAC_TO_HA.get(wink_mode, "") @property def hvac_modes(self): diff --git a/homeassistant/components/wink/fan.py b/homeassistant/components/wink/fan.py index 3aab66e353d..b918d596ef4 100644 --- a/homeassistant/components/wink/fan.py +++ b/homeassistant/components/wink/fan.py @@ -1,4 +1,6 @@ """Support for Wink fans.""" +from __future__ import annotations + import pywink from homeassistant.components.fan import ( @@ -67,7 +69,7 @@ class WinkFanDevice(WinkDevice, FanEntity): return self.wink.state() @property - def speed(self) -> str: + def speed(self) -> str | None: """Return the current speed.""" current_wink_speed = self.wink.current_fan_speed() if SPEED_AUTO == current_wink_speed: diff --git a/homeassistant/components/wink/sensor.py b/homeassistant/components/wink/sensor.py index f640a24def2..86199f44e91 100644 --- a/homeassistant/components/wink/sensor.py +++ b/homeassistant/components/wink/sensor.py @@ -62,7 +62,7 @@ class WinkSensorEntity(WinkDevice, SensorEntity): self.hass.data[DOMAIN]["entities"]["sensor"].append(self) @property - def state(self): + def native_value(self): """Return the state.""" state = None if self.capability == "humidity": @@ -82,7 +82,7 @@ class WinkSensorEntity(WinkDevice, SensorEntity): return state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index de70efda424..7ad0a7f52c2 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -83,7 +83,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): return self.name.lower().replace(" ", "_") @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -93,7 +93,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): return self._sensor_type @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._sensor.unit diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index 5866893888f..ca5391fdf96 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -1,31 +1,47 @@ """Switch implementation for Wireless Sensor Tags (wirelesstag.net).""" +from __future__ import annotations + import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.components.switch import ( + PLATFORM_SCHEMA, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor -ARM_TEMPERATURE = "temperature" -ARM_HUMIDITY = "humidity" -ARM_MOTION = "motion" -ARM_LIGHT = "light" -ARM_MOISTURE = "moisture" +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="temperature", + name="Arm Temperature", + ), + SwitchEntityDescription( + key="humidity", + name="Arm Humidity", + ), + SwitchEntityDescription( + key="motion", + name="Arm Motion", + ), + SwitchEntityDescription( + key="light", + name="Arm Light", + ), + SwitchEntityDescription( + key="moisture", + name="Arm Moisture", + ), +) -# Switch types: Name, tag sensor type -SWITCH_TYPES = { - ARM_TEMPERATURE: ["Arm Temperature", "temperature"], - ARM_HUMIDITY: ["Arm Humidity", "humidity"], - ARM_MOTION: ["Arm Motion", "motion"], - ARM_LIGHT: ["Arm Light", "light"], - ARM_MOISTURE: ["Arm Moisture", "moisture"], -} +SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SWITCH_TYPES)] + cv.ensure_list, [vol.In(SWITCH_KEYS)] ) } ) @@ -35,25 +51,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up switches for a Wireless Sensor Tags.""" platform = hass.data.get(WIRELESSTAG_DOMAIN) - switches = [] tags = platform.load_tags() - for switch_type in config.get(CONF_MONITORED_CONDITIONS): - for tag in tags.values(): - if switch_type in tag.allowed_monitoring_types: - switches.append(WirelessTagSwitch(platform, tag, switch_type)) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + entities = [ + WirelessTagSwitch(platform, tag, description) + for tag in tags.values() + for description in SWITCH_TYPES + if description.key in monitored_conditions + and description.key in tag.allowed_monitoring_types + ] - add_entities(switches, True) + add_entities(entities, True) class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): """A switch implementation for Wireless Sensor Tags.""" - def __init__(self, api, tag, switch_type): + def __init__(self, api, tag, description: SwitchEntityDescription): """Initialize a switch for Wireless Sensor Tag.""" super().__init__(api, tag) - self._switch_type = switch_type - self.sensor_type = SWITCH_TYPES[self._switch_type][1] - self._name = f"{self._tag.name} {SWITCH_TYPES[self._switch_type][0]}" + self.entity_description = description + self._name = f"{self._tag.name} {description.name}" def turn_on(self, **kwargs): """Turn on the switch.""" @@ -75,5 +93,5 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): @property def principal_value(self): """Provide actual value of switch.""" - attr_name = f"is_{self.sensor_type}_sensor_armed" + attr_name = f"is_{self.entity_description.key}_sensor_armed" return getattr(self._tag, attr_name, False) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 646243e309d..b70b8b5ca1a 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -792,6 +792,7 @@ class DataManager: ) for group in groups for measure in group.measures + if measure.type in WITHINGS_MEASURE_TYPE_MAP } async def async_get_sleep_summary(self) -> dict[MeasureType, Any]: diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index ca7391eb58e..0ca40d28440 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -30,11 +30,11 @@ class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): """Implementation of a Withings sensor.""" @property - def state(self) -> None | str | int | float: + def native_value(self) -> None | str | int | float: """Return the state of the entity.""" return self._state_data @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return self._attribute.unit_of_measurement diff --git a/homeassistant/components/withings/translations/en_GB.json b/homeassistant/components/withings/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/withings/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/translations/hu.json b/homeassistant/components/withings/translations/hu.json index ec8c628a485..e26cff027fc 100644 --- a/homeassistant/components/withings/translations/hu.json +++ b/homeassistant/components/withings/translations/hu.json @@ -25,6 +25,7 @@ "title": "Felhaszn\u00e1l\u00f3i profil." }, "reauth": { + "description": "A \u201e{profile}\u201d profilt \u00fajra hiteles\u00edteni kell, hogy tov\u00e1bbra is fogadni tudja a Withings adatokat.", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" } } diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 634f903c020..48d8443a0a9 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -48,7 +48,7 @@ class WLEDEstimatedCurrentSensor(WLEDEntity, SensorEntity): """Defines a WLED estimated current sensor.""" _attr_icon = "mdi:power" - _attr_unit_of_measurement = ELECTRIC_CURRENT_MILLIAMPERE + _attr_native_unit_of_measurement = ELECTRIC_CURRENT_MILLIAMPERE _attr_device_class = DEVICE_CLASS_CURRENT def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -66,7 +66,7 @@ class WLEDEstimatedCurrentSensor(WLEDEntity, SensorEntity): } @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self.coordinator.data.info.leds.power @@ -84,7 +84,7 @@ class WLEDUptimeSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_uptime" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the sensor.""" uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) return uptime.replace(microsecond=0).isoformat() @@ -95,7 +95,7 @@ class WLEDFreeHeapSensor(WLEDEntity, SensorEntity): _attr_icon = "mdi:memory" _attr_entity_registry_enabled_default = False - _attr_unit_of_measurement = DATA_BYTES + _attr_native_unit_of_measurement = DATA_BYTES def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED free heap sensor.""" @@ -104,7 +104,7 @@ class WLEDFreeHeapSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_free_heap" @property - def state(self) -> int: + def native_value(self) -> int: """Return the state of the sensor.""" return self.coordinator.data.info.free_heap @@ -113,7 +113,7 @@ class WLEDWifiSignalSensor(WLEDEntity, SensorEntity): """Defines a WLED Wi-Fi signal sensor.""" _attr_icon = "mdi:wifi" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE _attr_entity_registry_enabled_default = False def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -123,7 +123,7 @@ class WLEDWifiSignalSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_signal" @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" if not self.coordinator.data.info.wifi: return None @@ -134,7 +134,7 @@ class WLEDWifiRSSISensor(WLEDEntity, SensorEntity): """Defines a WLED Wi-Fi RSSI sensor.""" _attr_device_class = DEVICE_CLASS_SIGNAL_STRENGTH - _attr_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT _attr_entity_registry_enabled_default = False def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -144,7 +144,7 @@ class WLEDWifiRSSISensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_rssi" @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" if not self.coordinator.data.info.wifi: return None @@ -164,7 +164,7 @@ class WLEDWifiChannelSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_channel" @property - def state(self) -> int | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" if not self.coordinator.data.info.wifi: return None @@ -184,7 +184,7 @@ class WLEDWifiBSSIDSensor(WLEDEntity, SensorEntity): self._attr_unique_id = f"{coordinator.data.info.mac_address}_wifi_bssid" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return the state of the sensor.""" if not self.coordinator.data.info.wifi: return None diff --git a/homeassistant/components/wled/translations/ca.json b/homeassistant/components/wled/translations/ca.json index 2255a3cec0d..c4adacf21c2 100644 --- a/homeassistant/components/wled/translations/ca.json +++ b/homeassistant/components/wled/translations/ca.json @@ -13,7 +13,7 @@ "data": { "host": "Amfitri\u00f3" }, - "description": "Configura el teu WLED per integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 de WLED amb Home Assistant." }, "zeroconf_confirm": { "description": "Vols afegir el WLED `{name}` a Home Assistant?", diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 0d35d4bce5c..975ddbdd068 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -63,7 +63,7 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): return f"{self.wolf_object.name}" @property - def state(self): + def native_value(self): """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" if self.wolf_object.parameter_id in self.coordinator.data: new_state = self.coordinator.data[self.wolf_object.parameter_id] @@ -95,7 +95,7 @@ class WolfLinkHours(WolfLinkSensor): return "mdi:clock" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return TIME_HOURS @@ -109,7 +109,7 @@ class WolfLinkTemperature(WolfLinkSensor): return DEVICE_CLASS_TEMPERATURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return TEMP_CELSIUS @@ -123,7 +123,7 @@ class WolfLinkPressure(WolfLinkSensor): return DEVICE_CLASS_PRESSURE @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return PRESSURE_BAR @@ -132,7 +132,7 @@ class WolfLinkPercentage(WolfLinkSensor): """Class for percentage based entities.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" return self.wolf_object.unit @@ -146,9 +146,9 @@ class WolfLinkState(WolfLinkSensor): return "wolflink__state" @property - def state(self): + def native_value(self): """Return the state converting with supported values.""" - state = super().state + state = super().native_value resolved_state = [ item for item in self.wolf_object.items if item.value == int(state) ] diff --git a/homeassistant/components/wolflink/translations/hu.json b/homeassistant/components/wolflink/translations/hu.json index c7bb483155d..79d03d91034 100644 --- a/homeassistant/components/wolflink/translations/hu.json +++ b/homeassistant/components/wolflink/translations/hu.json @@ -12,13 +12,15 @@ "device": { "data": { "device_name": "Eszk\u00f6z" - } + }, + "title": "V\u00e1lassza ki a WOLF eszk\u00f6zt" }, "user": { "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "title": "WOLF SmartSet kapcsolat" } } } diff --git a/homeassistant/components/wolflink/translations/sensor.hu.json b/homeassistant/components/wolflink/translations/sensor.hu.json index b393660f35a..0a257e570cf 100644 --- a/homeassistant/components/wolflink/translations/sensor.hu.json +++ b/homeassistant/components/wolflink/translations/sensor.hu.json @@ -1,9 +1,87 @@ { "state": { "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "F\u00fcstg\u00e1zcsillap\u00edt\u00f3", + "absenkbetrieb": "Visszaes\u00e9s m\u00f3d", + "absenkstop": "Visszaes\u00e9s meg\u00e1ll\u00edt\u00e1sa", + "aktiviert": "Aktiv\u00e1lt", + "antilegionellenfunktion": "Anti-legionella funkci\u00f3", + "at_abschaltung": "OT le\u00e1ll\u00edt\u00e1s", + "at_frostschutz": "OT fagyv\u00e9delem", + "aus": "Letiltva", + "auto": "Automatikus", "auto_off_cool": "AutomataKiH\u0171t\u00e9s", + "auto_on_cool": "AutomatikusH\u0171t\u00e9s", "automatik_aus": "Automatikus kikapcsol\u00e1s", - "permanent": "\u00c1lland\u00f3" + "automatik_ein": "Automatikus bekapcsol\u00e1s", + "bereit_keine_ladung": "K\u00e9sz, nincs bet\u00f6ltve", + "betrieb_ohne_brenner": "Munka \u00e9g\u0151 n\u00e9lk\u00fcl", + "cooling": "H\u0171t\u00e9s", + "deaktiviert": "Inakt\u00edv", + "dhw_prior": "DHW Priorit\u00e1s", + "eco": "Takar\u00e9kos", + "ein": "Enged\u00e9lyezve", + "estrichtrocknung": "Padl\u00f3sz\u00e1r\u00edt\u00e1si", + "externe_deaktivierung": "K\u00fcls\u0151 deaktiv\u00e1l\u00e1s", + "fernschalter_ein": "T\u00e1vir\u00e1ny\u00edt\u00f3 enged\u00e9lyezve", + "frost_heizkreis": "F\u0171t\u0151k\u00f6r fagy\u00e1s", + "frost_warmwasser": "DHW fagy", + "frostschutz": "Fagyv\u00e9delem", + "gasdruck": "G\u00e1znyom\u00e1s", + "glt_betrieb": "BMS m\u00f3d", + "gradienten_uberwachung": "\u00c1tmenet monitoroz\u00e1s", + "heizbetrieb": "F\u0171t\u00e9si m\u00f3d", + "heizgerat_mit_speicher": "Kaz\u00e1n hengerrel", + "heizung": "F\u0171t\u00e9s", + "initialisierung": "Inicializ\u00e1l\u00e1s", + "kalibration": "Kalibr\u00e1ci\u00f3", + "kalibration_heizbetrieb": "F\u0171t\u00e9si m\u00f3d kalibr\u00e1l\u00e1sa", + "kalibration_kombibetrieb": "Kombin\u00e1lt m\u00f3d kalibr\u00e1l\u00e1sa", + "kalibration_warmwasserbetrieb": "DHW kalibr\u00e1l\u00e1s", + "kaskadenbetrieb": "Kaszk\u00e1d m\u0171k\u00f6d\u00e9s", + "kombibetrieb": "Kombin\u00e1lt m\u00f3d", + "kombigerat": "Kombin\u00e1lt kaz\u00e1n", + "kombigerat_mit_solareinbindung": "Kombin\u00e1lt kaz\u00e1n napelemes integr\u00e1ci\u00f3val", + "mindest_kombizeit": "Minim\u00e1lis kombin\u00e1lt id\u0151", + "nachlauf_heizkreispumpe": "A f\u0171t\u0151k\u00f6r szivatty\u00fa bej\u00e1rat\u00e1sa", + "nachspulen": "Ut\u00f3\u00f6bl\u00edt\u00e9s", + "nur_heizgerat": "Csak kaz\u00e1n", + "parallelbetrieb": "P\u00e1rhuzamos \u00fczemm\u00f3d", + "partymodus": "Party m\u00f3d", + "perm_cooling": "\u00c1lland\u00f3H\u0171t\u00e9s", + "permanent": "\u00c1lland\u00f3", + "permanentbetrieb": "\u00c1lland\u00f3 \u00fczemm\u00f3d", + "reduzierter_betrieb": "Korl\u00e1tozott m\u00f3d", + "rt_abschaltung": "RT le\u00e1ll\u00edt\u00e1s", + "rt_frostschutz": "RT fagyv\u00e9delem", + "ruhekontakt": "Pihen\u0151 kapcsolat", + "schornsteinfeger": "Emisszi\u00f3s vizsg\u00e1lat", + "smart_grid": "SmartGrid", + "smart_home": "OkosOtthon", + "softstart": "L\u00e1gy ind\u00edt\u00e1s", + "solarbetrieb": "Napenergia \u00fczemm\u00f3d", + "sparbetrieb": "Gazdas\u00e1gos m\u00f3d", + "sparen": "Gazdas\u00e1gos", + "spreizung_hoch": "dT t\u00fal sz\u00e9les", + "spreizung_kf": "Spread KF", + "stabilisierung": "Stabiliz\u00e1ci\u00f3", + "standby": "K\u00e9szenl\u00e9t", + "start": "Indul\u00e1s", + "storung": "Hiba", + "taktsperre": "Anti-ciklus", + "telefonfernschalter": "Telefonos t\u00e1vkapcsol\u00f3", + "test": "Teszt", + "tpw": "TPW", + "urlaubsmodus": "Nyaral\u00e1s \u00fczemm\u00f3d", + "ventilprufung": "Szelep teszt", + "vorspulen": "Bel\u00e9p\u00e9si sz\u00e1r\u00edt\u00e1s", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW gyorsind\u00edt\u00e1s", + "warmwasserbetrieb": "DHW m\u00f3d", + "warmwassernachlauf": "DHW befut\u00e1s", + "warmwasservorrang": "DHW priorit\u00e1s", + "zunden": "Gy\u00fajt\u00e1s" } } } \ No newline at end of file diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index de5b3991e3f..74da12f7f61 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -54,7 +54,7 @@ class WorldClockSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 0fa65957e40..4d7a32605b0 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -88,7 +88,7 @@ class WorldTidesInfoSensor(SensorEntity): return attr @property - def state(self): + def native_value(self): """Return the state of the device.""" if self.data: if "High" in str(self.data["extremes"][0]["type"]): diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index e7600670c52..b34481d0990 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -68,12 +68,12 @@ class WorxLandroidSensor(SensorEntity): return f"worxlandroid-{self.sensor}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of the sensor.""" if self.sensor == "battery": return PERCENTAGE diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 153d496a7d6..bc0023ac54f 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -88,7 +88,7 @@ class WashingtonStateTransportSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -96,7 +96,7 @@ class WashingtonStateTransportSensor(SensorEntity): class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): """Travel time sensor from WSDOT.""" - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__(self, name, access_code, travel_time_id): """Construct a travel time sensor.""" diff --git a/homeassistant/components/xbee/__init__.py b/homeassistant/components/xbee/__init__.py index 13cd4217b4d..5ca9e4ef6f7 100644 --- a/homeassistant/components/xbee/__init__.py +++ b/homeassistant/components/xbee/__init__.py @@ -369,7 +369,7 @@ class XBeeDigitalOut(XBeeDigitalIn): class XBeeAnalogIn(SensorEntity): """Representation of a GPIO pin configured as an analog input.""" - _attr_unit_of_measurement = PERCENTAGE + _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, config, device): """Initialize the XBee analog in device.""" @@ -416,7 +416,7 @@ class XBeeAnalogIn(SensorEntity): return self._config.should_poll @property - def state(self): + def sensor_state(self): """Return the state of the entity.""" return self._value diff --git a/homeassistant/components/xbee/sensor.py b/homeassistant/components/xbee/sensor.py index b1d5ece7d57..8dae25ad5e1 100644 --- a/homeassistant/components/xbee/sensor.py +++ b/homeassistant/components/xbee/sensor.py @@ -47,7 +47,7 @@ class XBeeTemperatureSensor(SensorEntity): """Representation of XBee Pro temperature sensor.""" _attr_device_class = DEVICE_CLASS_TEMPERATURE - _attr_unit_of_measurement = TEMP_CELSIUS + _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__(self, config, device): """Initialize the sensor.""" @@ -61,7 +61,7 @@ class XBeeTemperatureSensor(SensorEntity): return self._config.name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._temp diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 6e651cdbcf3..d54d79532ca 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import api, config_flow @@ -50,7 +51,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["media_player", "remote", "binary_sensor", "sensor"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the xbox component.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/xbox/browse_media.py b/homeassistant/components/xbox/browse_media.py index 0c3eec95c6f..d1438a46f23 100644 --- a/homeassistant/components/xbox/browse_media.py +++ b/homeassistant/components/xbox/browse_media.py @@ -45,7 +45,7 @@ async def build_item_response( """Create response payload for the provided media query.""" apps: InstalledPackagesList = await client.smartglass.get_installed_apps(device_id) - if media_content_type in [None, "library"]: + if media_content_type in (None, "library"): library_info = BrowseMedia( media_class=MEDIA_CLASS_DIRECTORY, media_content_id="library", diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 9aa0de4a727..854c0b007f6 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -33,7 +33,7 @@ class XboxSensorEntity(XboxBaseSensorEntity, SensorEntity): """Representation of a Xbox presence state.""" @property - def state(self): + def native_value(self): """Return the state of the requested attribute.""" if not self.coordinator.last_update_success: return None diff --git a/homeassistant/components/xbox/translations/en_GB.json b/homeassistant/components/xbox/translations/en_GB.json new file mode 100644 index 00000000000..ddf7ee6d5dd --- /dev/null +++ b/homeassistant/components/xbox/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorise URL." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index 2717bc1ad62..c09b707cba0 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -98,7 +98,7 @@ class XboxSensor(SensorEntity): return False @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/xeoma/camera.py b/homeassistant/components/xeoma/camera.py index d6f313c0382..049b4bfcbc0 100644 --- a/homeassistant/components/xeoma/camera.py +++ b/homeassistant/components/xeoma/camera.py @@ -1,4 +1,6 @@ """Support for Xeoma Cameras.""" +from __future__ import annotations + import logging from pyxeoma.xeoma import Xeoma, XeomaError @@ -109,7 +111,9 @@ class XeomaCamera(Camera): self._password = password self._last_image = None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 359d6c8b896..016fe7dd2ba 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,12 +1,13 @@ """This component provides support for Xiaomi Cameras.""" -import asyncio +from __future__ import annotations + from ftplib import FTP, error_perm import logging from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ( @@ -138,7 +139,9 @@ class XiaomiCamera(Camera): return f"ftp://{self.user}:{self.passwd}@{host}:{self.port}{ftp.pwd()}/{video}" - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: @@ -149,11 +152,12 @@ class XiaomiCamera(Camera): url = await self.hass.async_add_executor_job(self.get_latest_video_url, host) if url != self._last_url: - ffmpeg = ImageFrame(self._manager.binary) - self._last_image = await asyncio.shield( - ffmpeg.get_image( - url, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments - ) + self._last_image = await ffmpeg.async_get_image( + self.hass, + url, + extra_cmd=self._extra_arguments, + width=width, + height=height, ) self._last_url = url diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 3d9437e3778..41a99426c67 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -32,23 +32,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for entity in gateway.devices["binary_sensor"]: model = entity["model"] - if model in ["motion", "sensor_motion", "sensor_motion.aq2"]: + if model in ("motion", "sensor_motion", "sensor_motion.aq2"): entities.append(XiaomiMotionSensor(entity, hass, gateway, config_entry)) - elif model in ["magnet", "sensor_magnet", "sensor_magnet.aq2"]: + elif model in ("magnet", "sensor_magnet", "sensor_magnet.aq2"): entities.append(XiaomiDoorSensor(entity, gateway, config_entry)) elif model == "sensor_wleak.aq1": entities.append(XiaomiWaterLeakSensor(entity, gateway, config_entry)) - elif model in ["smoke", "sensor_smoke"]: + elif model in ("smoke", "sensor_smoke"): entities.append(XiaomiSmokeSensor(entity, gateway, config_entry)) - elif model in ["natgas", "sensor_natgas"]: + elif model in ("natgas", "sensor_natgas"): entities.append(XiaomiNatgasSensor(entity, gateway, config_entry)) - elif model in [ + elif model in ( "switch", "sensor_switch", "sensor_switch.aq2", "sensor_switch.aq3", "remote.b1acn01", - ]: + ): if "proto" not in entity or int(entity["proto"][0:1]) == 1: data_key = "status" else: @@ -56,13 +56,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append( XiaomiButton(entity, "Switch", data_key, hass, gateway, config_entry) ) - elif model in [ + elif model in ( "86sw1", "sensor_86sw1", "sensor_86sw1.aq1", "remote.b186acn01", "remote.b186acn02", - ]: + ): if "proto" not in entity or int(entity["proto"][0:1]) == 1: data_key = "channel_0" else: @@ -72,13 +72,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entity, "Wall Switch", data_key, hass, gateway, config_entry ) ) - elif model in [ + elif model in ( "86sw2", "sensor_86sw2", "sensor_86sw2.aq1", "remote.b286acn01", "remote.b286acn02", - ]: + ): if "proto" not in entity or int(entity["proto"][0:1]) == 1: data_key_left = "channel_0" data_key_right = "channel_1" @@ -115,9 +115,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_entry, ) ) - elif model in ["cube", "sensor_cube", "sensor_cube.aqgl01"]: + elif model in ("cube", "sensor_cube", "sensor_cube.aqgl01"): entities.append(XiaomiCube(entity, hass, gateway, config_entry)) - elif model in ["vibration", "vibration.aq1"]: + elif model in ("vibration", "vibration.aq1"): entities.append( XiaomiVibration(entity, "Vibration", "status", gateway, config_entry) ) diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index 0ef74da83ff..db41a4d719a 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -16,7 +16,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for device in gateway.devices["cover"]: model = device["model"] - if model in ["curtain", "curtain.aq2", "curtain.hagl04"]: + if model in ("curtain", "curtain.aq2", "curtain.hagl04"): if "proto" not in device or int(device["proto"][0:1]) == 1: data_key = DATA_KEY_PROTO_V1 else: diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 30f72a7ba59..4064df5f259 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -24,7 +24,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for device in gateway.devices["light"]: model = device["model"] - if model in ["gateway", "gateway.v3"]: + if model in ("gateway", "gateway.v3"): entities.append( XiaomiGatewayLight(device, "Gateway Light", gateway, config_entry) ) diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index fa3d265f12f..cad3afb11ba 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -47,7 +47,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append( XiaomiSensor(device, "Humidity", "humidity", gateway, config_entry) ) - elif device["model"] in ["weather", "weather.v1"]: + elif device["model"] in ("weather", "weather.v1"): entities.append( XiaomiSensor( device, "Temperature", "temperature", gateway, config_entry @@ -63,13 +63,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append( XiaomiSensor(device, "Illumination", "lux", gateway, config_entry) ) - elif device["model"] in ["gateway", "gateway.v3", "acpartner.v3"]: + elif device["model"] in ("gateway", "gateway.v3", "acpartner.v3"): entities.append( XiaomiSensor( device, "Illumination", "illumination", gateway, config_entry ) ) - elif device["model"] in ["vibration"]: + elif device["model"] in ("vibration",): entities.append( XiaomiSensor( device, "Bed Activity", "bed_activity", gateway, config_entry @@ -125,7 +125,7 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): return None @property - def unit_of_measurement(self): + 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] @@ -142,7 +142,7 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): ) @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -151,13 +151,13 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): value = data.get(self._data_key) if value is None: return False - if self._data_key in ["coordination", "status"]: + if self._data_key in ("coordination", "status"): self._state = value return True value = float(value) - if self._data_key in ["temperature", "humidity", "pressure"]: + if self._data_key in ("temperature", "humidity", "pressure"): value /= 100 - elif self._data_key in ["illumination"]: + elif self._data_key in ("illumination",): value = max(value - 300, 0) if self._data_key == "temperature" and (value < -50 or value > 60): return False @@ -165,7 +165,7 @@ class XiaomiSensor(XiaomiDevice, SensorEntity): return False if self._data_key == "pressure" and value == 0: return False - if self._data_key in ["illumination", "lux"]: + if self._data_key in ("illumination", "lux"): self._state = round(value) else: self._state = round(value, 1) @@ -176,7 +176,7 @@ class XiaomiBatterySensor(XiaomiDevice, SensorEntity): """Representation of a XiaomiSensor.""" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return PERCENTAGE @@ -186,7 +186,7 @@ class XiaomiBatterySensor(XiaomiDevice, SensorEntity): return DEVICE_CLASS_BATTERY @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index c17cf080a60..139a7a57dbc 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -37,34 +37,34 @@ async def async_setup_entry(hass, config_entry, async_add_entities): device, "Plug", data_key, True, gateway, config_entry ) ) - elif model in [ + elif model in ( "ctrl_neutral1", "ctrl_neutral1.aq1", "switch_b1lacn02", "switch.b1lacn02", - ]: + ): entities.append( XiaomiGenericSwitch( device, "Wall Switch", "channel_0", False, gateway, config_entry ) ) - elif model in [ + elif model in ( "ctrl_ln1", "ctrl_ln1.aq1", "switch_b1nacn02", "switch.b1nacn02", - ]: + ): entities.append( XiaomiGenericSwitch( device, "Wall Switch LN", "channel_0", False, gateway, config_entry ) ) - elif model in [ + elif model in ( "ctrl_neutral2", "ctrl_neutral2.aq1", "switch_b2lacn02", "switch.b2lacn02", - ]: + ): entities.append( XiaomiGenericSwitch( device, @@ -85,12 +85,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_entry, ) ) - elif model in [ + elif model in ( "ctrl_ln2", "ctrl_ln2.aq1", "switch_b2nacn02", "switch.b2nacn02", - ]: + ): entities.append( XiaomiGenericSwitch( device, @@ -111,7 +111,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_entry, ) ) - elif model in ["86plug", "ctrl_86plug", "ctrl_86plug.aq1"]: + elif model in ("86plug", "ctrl_86plug", "ctrl_86plug.aq1"): if "proto" not in device or int(device["proto"][0:1]) == 1: data_key = "status" else: diff --git a/homeassistant/components/xiaomi_aqara/translations/de.json b/homeassistant/components/xiaomi_aqara/translations/de.json index bc87f461c33..469fa14bcc1 100644 --- a/homeassistant/components/xiaomi_aqara/translations/de.json +++ b/homeassistant/components/xiaomi_aqara/translations/de.json @@ -33,7 +33,7 @@ "data": { "host": "IP-Adresse (optional)", "interface": "Die zu verwendende Netzwerkschnittstelle", - "mac": "MAC-Adresse" + "mac": "MAC-Adresse (optional)" }, "description": "Stelle eine Verbindung zu deinem Xiaomi Aqara Gateway her. Wenn die IP- und MAC-Adressen leer bleiben, wird die automatische Erkennung verwendet", "title": "Xiaomi Aqara Gateway" diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 36ee89ba7a0..cde597432df 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -3,7 +3,17 @@ from datetime import timedelta import logging import async_timeout -from miio import AirHumidifier, AirHumidifierMiot, DeviceException +from miio import ( + AirFresh, + AirHumidifier, + AirHumidifierMiot, + AirHumidifierMjjsq, + AirPurifier, + AirPurifierMiot, + DeviceException, + Fan, + FanP5, +) from miio.gateway.gateway import GatewayException from homeassistant import config_entries, core @@ -21,11 +31,16 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_FAN_P5, MODELS_AIR_MONITOR, MODELS_FAN, + MODELS_FAN_MIIO, MODELS_HUMIDIFIER, + MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, + MODELS_HUMIDIFIER_MJJSQ, MODELS_LIGHT, + MODELS_PURIFIER_MIOT, MODELS_SWITCH, MODELS_VACUUM, ) @@ -35,8 +50,15 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] -FAN_PLATFORMS = ["fan"] -HUMIDIFIER_PLATFORMS = ["humidifier", "number", "select", "sensor", "switch"] +FAN_PLATFORMS = ["fan", "number", "select", "sensor", "switch"] +HUMIDIFIER_PLATFORMS = [ + "binary_sensor", + "humidifier", + "number", + "select", + "sensor", + "switch", +] LIGHT_PLATFORMS = ["light"] VACUUM_PLATFORMS = ["vacuum"] AIR_MONITOR_PLATFORMS = ["air_quality", "sensor"] @@ -99,31 +121,61 @@ async def async_create_miio_device_and_coordinator( token = entry.data[CONF_TOKEN] name = entry.title device = None + migrate = False - if model not in MODELS_HUMIDIFIER: + if model not in MODELS_HUMIDIFIER and model not in MODELS_FAN: return _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + # Humidifiers if model in MODELS_HUMIDIFIER_MIOT: device = AirHumidifierMiot(host, token) - else: + migrate = True + elif model in MODELS_HUMIDIFIER_MJJSQ: + device = AirHumidifierMjjsq(host, token, model=model) + migrate = True + elif model in MODELS_HUMIDIFIER_MIIO: device = AirHumidifier(host, token, model=model) + migrate = True + # Airpurifiers and Airfresh + elif model in MODELS_PURIFIER_MIOT: + device = AirPurifierMiot(host, token) + elif model.startswith("zhimi.airpurifier."): + device = AirPurifier(host, token) + elif model.startswith("zhimi.airfresh."): + device = AirFresh(host, token) + # Pedestal fans + elif model == MODEL_FAN_P5: + device = FanP5(host, token) + elif model in MODELS_FAN_MIIO: + device = Fan(host, token, model=model) + else: + _LOGGER.error( + "Unsupported device found! Please create an issue at " + "https://github.com/syssi/xiaomi_airpurifier/issues " + "and provide the following data: %s", + model, + ) + return - # Removing fan platform entity for humidifiers and migrate the name to the config entry for migration - entity_registry = er.async_get(hass) - entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id) - if entity_id: - # This check is entities that have a platform migration only and should be removed in the future - if migrate_entity_name := entity_registry.async_get(entity_id).name: - hass.config_entries.async_update_entry(entry, title=migrate_entity_name) - entity_registry.async_remove(entity_id) + if migrate: + # Removing fan platform entity for humidifiers and migrate the name to the config entry for migration + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id) + if entity_id: + # This check is entities that have a platform migration only and should be removed in the future + if migrate_entity_name := entity_registry.async_get(entity_id).name: + hass.config_entries.async_update_entry(entry, title=migrate_entity_name) + entity_registry.async_remove(entity_id) async def async_update_data(): """Fetch data from the device using async_add_executor_job.""" try: async with async_timeout.timeout(10): - return await hass.async_add_executor_job(device.status) + state = await hass.async_add_executor_job(device.status) + _LOGGER.debug("Got new state: %s", state) + return state except DeviceException as ex: raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 4b56c60cd82..372a1b62e73 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -2,18 +2,14 @@ import logging from miio import AirQualityMonitor, AirQualityMonitorCGDN1, DeviceException -import voluptuous as vol -from homeassistant.components.air_quality import PLATFORM_SCHEMA, AirQualityEntity -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN -import homeassistant.helpers.config_validation as cv +from homeassistant.components.air_quality import AirQualityEntity +from homeassistant.const import CONF_HOST, CONF_TOKEN from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, CONF_MODEL, - DOMAIN, MODEL_AIRQUALITYMONITOR_B1, MODEL_AIRQUALITYMONITOR_CGDN1, MODEL_AIRQUALITYMONITOR_S1, @@ -30,14 +26,6 @@ ATTR_TVOC = "total_volatile_organic_compounds" ATTR_TEMP = "temperature" ATTR_HUM = "humidity" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - PROP_TO_ATTR = { "carbon_dioxide_equivalent": ATTR_CO2E, "total_volatile_organic_compounds": ATTR_TVOC, @@ -249,21 +237,6 @@ DEVICE_MAP = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Air Quality 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, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi Air Quality from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py new file mode 100644 index 00000000000..6254c00916e --- /dev/null +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -0,0 +1,114 @@ +"""Support for Xiaomi Miio binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Callable + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorEntity, + BinarySensorEntityDescription, +) + +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, + DOMAIN, + KEY_COORDINATOR, + KEY_DEVICE, + MODELS_HUMIDIFIER_MIIO, + MODELS_HUMIDIFIER_MIOT, + MODELS_HUMIDIFIER_MJJSQ, +) +from .device import XiaomiCoordinatedMiioEntity + +ATTR_NO_WATER = "no_water" +ATTR_WATER_TANK_DETACHED = "water_tank_detached" + + +@dataclass +class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): + """A class that describes binary sensor entities.""" + + value: Callable | None = None + + +BINARY_SENSOR_TYPES = ( + XiaomiMiioBinarySensorDescription( + key=ATTR_NO_WATER, + name="Water Tank Empty", + icon="mdi:water-off-outline", + ), + XiaomiMiioBinarySensorDescription( + key=ATTR_WATER_TANK_DETACHED, + name="Water Tank", + icon="mdi:car-coolant-level", + device_class=DEVICE_CLASS_CONNECTIVITY, + value=lambda value: not value, + ), +) + +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) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Xiaomi sensor from a config entry.""" + entities = [] + + if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + model = config_entry.data[CONF_MODEL] + sensors = [] + if model in MODELS_HUMIDIFIER_MIIO: + sensors = HUMIDIFIER_MIIO_BINARY_SENSORS + elif model in MODELS_HUMIDIFIER_MIOT: + sensors = HUMIDIFIER_MIOT_BINARY_SENSORS + elif model in MODELS_HUMIDIFIER_MJJSQ: + sensors = HUMIDIFIER_MJJSQ_BINARY_SENSORS + for description in BINARY_SENSOR_TYPES: + if description.key not in sensors: + continue + entities.append( + XiaomiGenericBinarySensor( + f"{config_entry.title} {description.name}", + hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE], + config_entry, + f"{description.key}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) + ) + + async_add_entities(entities) + + +class XiaomiGenericBinarySensor(XiaomiCoordinatedMiioEntity, BinarySensorEntity): + """Representation of a Xiaomi Humidifier binary sensor.""" + + def __init__(self, name, device, entry, unique_id, coordinator, description): + """Initialize the entity.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self.entity_description = description + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + state = self._extract_value_from_attribute( + self.coordinator.data, self.entity_description.key + ) + if self.entity_description.value is not None and state is not None: + return self.entity_description.value(state) + + return state + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index a2f7679bf1b..b670582c069 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -52,16 +52,36 @@ MODEL_AIRHUMIDIFIER_V1 = "zhimi.humidifier.v1" MODEL_AIRHUMIDIFIER_CA1 = "zhimi.humidifier.ca1" MODEL_AIRHUMIDIFIER_CA4 = "zhimi.humidifier.ca4" MODEL_AIRHUMIDIFIER_CB1 = "zhimi.humidifier.cb1" +MODEL_AIRHUMIDIFIER_JSQ = "deerma.humidifier.jsq" +MODEL_AIRHUMIDIFIER_JSQ1 = "deerma.humidifier.jsq1" +MODEL_AIRHUMIDIFIER_MJJSQ = "deerma.humidifier.mjjsq" MODEL_AIRFRESH_VA2 = "zhimi.airfresh.va2" +MODEL_FAN_P5 = "dmaker.fan.p5" +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" + +MODELS_FAN_MIIO = [ + MODEL_FAN_P5, + MODEL_FAN_SA1, + MODEL_FAN_V2, + MODEL_FAN_V3, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, +] + MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3, MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, ] -MODELS_HUMIDIFIER_MIOT = [MODEL_AIRHUMIDIFIER_CA4] -MODELS_FAN_MIIO = [ +MODELS_PURIFIER_MIIO = [ MODEL_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V2, MODEL_AIRPURIFIER_V3, @@ -83,6 +103,12 @@ MODELS_HUMIDIFIER_MIIO = [ MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, ] +MODELS_HUMIDIFIER_MIOT = [MODEL_AIRHUMIDIFIER_CA4] +MODELS_HUMIDIFIER_MJJSQ = [ + MODEL_AIRHUMIDIFIER_JSQ, + MODEL_AIRHUMIDIFIER_JSQ1, + MODEL_AIRHUMIDIFIER_MJJSQ, +] # AirQuality Models MODEL_AIRQUALITYMONITOR_V1 = "zhimi.airmonitor.v1" @@ -116,8 +142,10 @@ MODELS_SWITCH = [ "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", ] -MODELS_FAN = MODELS_FAN_MIIO + MODELS_PURIFIER_MIOT -MODELS_HUMIDIFIER = MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO +MODELS_FAN = MODELS_PURIFIER_MIIO + MODELS_PURIFIER_MIOT + MODELS_FAN_MIIO +MODELS_HUMIDIFIER = ( + MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO + MODELS_HUMIDIFIER_MJJSQ +) MODELS_LIGHT = ( MODELS_LIGHT_EYECARE + MODELS_LIGHT_CEILING @@ -144,24 +172,8 @@ MODELS_ALL_DEVICES = ( MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY # Fan/Humidifier Services -SERVICE_SET_BUZZER_ON = "fan_set_buzzer_on" -SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" -SERVICE_SET_BUZZER = "set_buzzer" -SERVICE_SET_CLEAN = "set_clean" -SERVICE_SET_FAN_LED_ON = "fan_set_led_on" -SERVICE_SET_FAN_LED_OFF = "fan_set_led_off" -SERVICE_SET_FAN_LED = "fan_set_led" -SERVICE_SET_LED_BRIGHTNESS = "set_led_brightness" -SERVICE_SET_CHILD_LOCK_ON = "fan_set_child_lock_on" -SERVICE_SET_CHILD_LOCK_OFF = "fan_set_child_lock_off" -SERVICE_SET_CHILD_LOCK = "set_child_lock" -SERVICE_SET_LED_BRIGHTNESS = "fan_set_led_brightness" SERVICE_SET_FAVORITE_LEVEL = "fan_set_favorite_level" SERVICE_SET_FAN_LEVEL = "fan_set_fan_level" -SERVICE_SET_AUTO_DETECT_ON = "fan_set_auto_detect_on" -SERVICE_SET_AUTO_DETECT_OFF = "fan_set_auto_detect_off" -SERVICE_SET_LEARN_MODE_ON = "fan_set_learn_mode_on" -SERVICE_SET_LEARN_MODE_OFF = "fan_set_learn_mode_off" SERVICE_SET_VOLUME = "fan_set_volume" SERVICE_RESET_FILTER = "fan_reset_filter" SERVICE_SET_EXTRA_FEATURES = "fan_set_extra_features" @@ -214,23 +226,32 @@ FEATURE_SET_DRY = 2048 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_FLAGS_AIRPURIFIER = ( +FEATURE_FLAGS_AIRPURIFIER_MIIO = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_FAVORITE_LEVEL | FEATURE_SET_LEARN_MODE | FEATURE_RESET_FILTER | FEATURE_SET_EXTRA_FEATURES ) +FEATURE_FLAGS_AIRPURIFIER_MIOT = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_FAN_LEVEL + | FEATURE_SET_LED_BRIGHTNESS +) + FEATURE_FLAGS_AIRPURIFIER_PRO = ( FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_AUTO_DETECT | FEATURE_SET_VOLUME ) @@ -248,32 +269,25 @@ FEATURE_FLAGS_AIRPURIFIER_2S = ( | FEATURE_SET_FAVORITE_LEVEL ) -FEATURE_FLAGS_AIRPURIFIER_3 = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_FAN_LEVEL - | FEATURE_SET_LED_BRIGHTNESS -) +FEATURE_FLAGS_AIRPURIFIER_V1 = FEATURE_FLAGS_AIRPURIFIER_MIIO | FEATURE_SET_AUTO_DETECT FEATURE_FLAGS_AIRPURIFIER_V3 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED ) FEATURE_FLAGS_AIRHUMIDIFIER = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED_BRIGHTNESS - | FEATURE_SET_TARGET_HUMIDITY + FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_TARGET_HUMIDITY ) FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB = FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY +FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ = ( + FEATURE_SET_BUZZER | FEATURE_SET_LED | FEATURE_SET_TARGET_HUMIDITY +) + FEATURE_FLAGS_AIRHUMIDIFIER_CA4 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED_BRIGHTNESS | FEATURE_SET_TARGET_HUMIDITY | FEATURE_SET_DRY | FEATURE_SET_MOTOR_SPEED @@ -288,3 +302,19 @@ FEATURE_FLAGS_AIRFRESH = ( | FEATURE_RESET_FILTER | FEATURE_SET_EXTRA_FEATURES ) + +FEATURE_FLAGS_FAN_P5 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE_MAX_140 + | FEATURE_SET_LED + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) + +FEATURE_FLAGS_FAN = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_OSCILLATION_ANGLE + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_DELAY_OFF_COUNTDOWN +) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index feeadf2bccc..42828943d93 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,40 +1,27 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" import asyncio from enum import Enum -from functools import partial import logging import math -from miio import AirFresh, AirPurifier, AirPurifierMiot, DeviceException -from miio.airfresh import ( - LedBrightness as AirfreshLedBrightness, - OperationMode as AirfreshOperationMode, -) -from miio.airpurifier import ( - LedBrightness as AirpurifierLedBrightness, - OperationMode as AirpurifierOperationMode, -) -from miio.airpurifier_miot import ( - LedBrightness as AirpurifierMiotLedBrightness, - OperationMode as AirpurifierMiotOperationMode, +from miio.airfresh import OperationMode as AirfreshOperationMode +from miio.airpurifier import OperationMode as AirpurifierOperationMode +from miio.airpurifier_miot import OperationMode as AirpurifierMiotOperationMode +from miio.fan import ( + MoveDirection as FanMoveDirection, + OperationMode as FanOperationMode, ) import voluptuous as vol from homeassistant.components.fan import ( - PLATFORM_SCHEMA, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_MODE, - ATTR_TEMPERATURE, - CONF_HOST, - CONF_NAME, - CONF_TOKEN, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -45,43 +32,31 @@ from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, + FEATURE_FLAGS_AIRFRESH, + FEATURE_FLAGS_AIRPURIFIER_2S, + 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_P5, FEATURE_RESET_FILTER, - FEATURE_SET_AUTO_DETECT, - FEATURE_SET_BUZZER, - FEATURE_SET_CHILD_LOCK, FEATURE_SET_EXTRA_FEATURES, - FEATURE_SET_FAN_LEVEL, - FEATURE_SET_FAVORITE_LEVEL, - FEATURE_SET_LEARN_MODE, - FEATURE_SET_LED, - FEATURE_SET_LED_BRIGHTNESS, - FEATURE_SET_VOLUME, + KEY_COORDINATOR, + KEY_DEVICE, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V3, - MODELS_FAN, + MODEL_FAN_P5, + MODELS_FAN_MIIO, MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, - SERVICE_SET_AUTO_DETECT_OFF, - SERVICE_SET_AUTO_DETECT_ON, - SERVICE_SET_BUZZER_OFF, - SERVICE_SET_BUZZER_ON, - SERVICE_SET_CHILD_LOCK_OFF, - SERVICE_SET_CHILD_LOCK_ON, SERVICE_SET_EXTRA_FEATURES, - SERVICE_SET_FAN_LED_OFF, - SERVICE_SET_FAN_LED_ON, - SERVICE_SET_FAN_LEVEL, - SERVICE_SET_FAVORITE_LEVEL, - SERVICE_SET_LEARN_MODE_OFF, - SERVICE_SET_LEARN_MODE_ON, - SERVICE_SET_LED_BRIGHTNESS, - SERVICE_SET_VOLUME, - SUCCESS, ) -from .device import XiaomiMiioEntity +from .device import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) @@ -90,68 +65,26 @@ DATA_KEY = "fan.xiaomi_miio" CONF_MODEL = "model" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MODEL): vol.In(MODELS_FAN), - } -) - ATTR_MODEL = "model" +ATTR_MODE_NATURE = "Nature" +ATTR_MODE_NORMAL = "Normal" + # Air Purifier -ATTR_HUMIDITY = "humidity" -ATTR_AIR_QUALITY_INDEX = "aqi" -ATTR_FILTER_HOURS_USED = "filter_hours_used" -ATTR_FILTER_LIFE = "filter_life_remaining" -ATTR_FAVORITE_LEVEL = "favorite_level" -ATTR_BUZZER = "buzzer" -ATTR_CHILD_LOCK = "child_lock" -ATTR_LED = "led" -ATTR_LED_BRIGHTNESS = "led_brightness" -ATTR_MOTOR_SPEED = "motor_speed" -ATTR_AVERAGE_AIR_QUALITY_INDEX = "average_aqi" -ATTR_PURIFY_VOLUME = "purify_volume" ATTR_BRIGHTNESS = "brightness" -ATTR_LEVEL = "level" ATTR_FAN_LEVEL = "fan_level" -ATTR_MOTOR2_SPEED = "motor2_speed" -ATTR_ILLUMINANCE = "illuminance" -ATTR_FILTER_RFID_PRODUCT_ID = "filter_rfid_product_id" -ATTR_FILTER_RFID_TAG = "filter_rfid_tag" -ATTR_FILTER_TYPE = "filter_type" -ATTR_LEARN_MODE = "learn_mode" ATTR_SLEEP_TIME = "sleep_time" ATTR_SLEEP_LEARN_COUNT = "sleep_mode_learn_count" ATTR_EXTRA_FEATURES = "extra_features" ATTR_FEATURES = "features" ATTR_TURBO_MODE_SUPPORTED = "turbo_mode_supported" -ATTR_AUTO_DETECT = "auto_detect" ATTR_SLEEP_MODE = "sleep_mode" -ATTR_VOLUME = "volume" ATTR_USE_TIME = "use_time" ATTR_BUTTON_PRESSED = "button_pressed" -# Air Fresh -ATTR_CO2 = "co2" - # Map attributes to properties of the state object AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { - ATTR_TEMPERATURE: "temperature", - ATTR_HUMIDITY: "humidity", - ATTR_AIR_QUALITY_INDEX: "aqi", ATTR_MODE: "mode", - ATTR_FILTER_HOURS_USED: "filter_hours_used", - ATTR_FILTER_LIFE: "filter_life_remaining", - ATTR_FAVORITE_LEVEL: "favorite_level", - ATTR_CHILD_LOCK: "child_lock", - ATTR_LED: "led", - ATTR_MOTOR_SPEED: "motor_speed", - ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi", - ATTR_LEARN_MODE: "learn_mode", ATTR_EXTRA_FEATURES: "extra_features", ATTR_TURBO_MODE_SUPPORTED: "turbo_mode_supported", ATTR_BUTTON_PRESSED: "button_pressed", @@ -159,120 +92,44 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { AVAILABLE_ATTRIBUTES_AIRPURIFIER = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_PURIFY_VOLUME: "purify_volume", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", - ATTR_AUTO_DETECT: "auto_detect", ATTR_USE_TIME: "use_time", - ATTR_BUZZER: "buzzer", - ATTR_LED_BRIGHTNESS: "led_brightness", ATTR_SLEEP_MODE: "sleep_mode", } AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_PURIFY_VOLUME: "purify_volume", ATTR_USE_TIME: "use_time", - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_ILLUMINANCE: "illuminance", - ATTR_MOTOR2_SPEED: "motor2_speed", - ATTR_VOLUME: "volume", - # perhaps supported but unconfirmed - ATTR_AUTO_DETECT: "auto_detect", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", } -AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = { - **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_ILLUMINANCE: "illuminance", - ATTR_MOTOR2_SPEED: "motor2_speed", - ATTR_VOLUME: "volume", -} - -AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S = { - **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, - ATTR_BUZZER: "buzzer", - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_ILLUMINANCE: "illuminance", -} - -AVAILABLE_ATTRIBUTES_AIRPURIFIER_3 = { - ATTR_TEMPERATURE: "temperature", - ATTR_HUMIDITY: "humidity", - ATTR_AIR_QUALITY_INDEX: "aqi", +AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT = { ATTR_MODE: "mode", - ATTR_FILTER_HOURS_USED: "filter_hours_used", - ATTR_FILTER_LIFE: "filter_life_remaining", - ATTR_FAVORITE_LEVEL: "favorite_level", - ATTR_CHILD_LOCK: "child_lock", - ATTR_LED: "led", - ATTR_MOTOR_SPEED: "motor_speed", - ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi", - ATTR_PURIFY_VOLUME: "purify_volume", ATTR_USE_TIME: "use_time", - ATTR_BUZZER: "buzzer", - ATTR_LED_BRIGHTNESS: "led_brightness", - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_FAN_LEVEL: "fan_level", } +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_AIR_QUALITY_INDEX: "aqi", ATTR_MODE: "mode", - ATTR_LED: "led", - ATTR_BUZZER: "buzzer", - ATTR_CHILD_LOCK: "child_lock", - ATTR_ILLUMINANCE: "illuminance", - ATTR_FILTER_HOURS_USED: "filter_hours_used", - ATTR_FILTER_LIFE: "filter_life_remaining", - ATTR_MOTOR_SPEED: "motor_speed", - # perhaps supported but unconfirmed - ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi", - ATTR_VOLUME: "volume", - ATTR_MOTOR2_SPEED: "motor2_speed", - ATTR_FILTER_RFID_PRODUCT_ID: "filter_rfid_product_id", - ATTR_FILTER_RFID_TAG: "filter_rfid_tag", - ATTR_FILTER_TYPE: "filter_type", - ATTR_PURIFY_VOLUME: "purify_volume", - ATTR_LEARN_MODE: "learn_mode", ATTR_SLEEP_TIME: "sleep_time", ATTR_SLEEP_LEARN_COUNT: "sleep_mode_learn_count", ATTR_EXTRA_FEATURES: "extra_features", - ATTR_AUTO_DETECT: "auto_detect", ATTR_USE_TIME: "use_time", ATTR_BUTTON_PRESSED: "button_pressed", } AVAILABLE_ATTRIBUTES_AIRFRESH = { - ATTR_TEMPERATURE: "temperature", - ATTR_AIR_QUALITY_INDEX: "aqi", - ATTR_AVERAGE_AIR_QUALITY_INDEX: "average_aqi", - ATTR_CO2: "co2", - ATTR_HUMIDITY: "humidity", ATTR_MODE: "mode", - ATTR_LED: "led", - ATTR_LED_BRIGHTNESS: "led_brightness", - ATTR_BUZZER: "buzzer", - ATTR_CHILD_LOCK: "child_lock", - ATTR_FILTER_LIFE: "filter_life_remaining", - ATTR_FILTER_HOURS_USED: "filter_hours_used", ATTR_USE_TIME: "use_time", - ATTR_MOTOR_SPEED: "motor_speed", 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 @@ -280,7 +137,6 @@ 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"] -PRESET_MODES_AIRPURIFIER_3 = ["Auto", "Silent", "Favorite", "Fan"] OPERATION_MODES_AIRPURIFIER_V3 = [ "Auto", "Silent", @@ -302,239 +158,126 @@ PRESET_MODES_AIRPURIFIER_V3 = [ OPERATION_MODES_AIRFRESH = ["Auto", "Silent", "Interval", "Low", "Middle", "Strong"] PRESET_MODES_AIRFRESH = ["Auto", "Interval"] -FEATURE_FLAGS_AIRPURIFIER = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_LEARN_MODE - | FEATURE_RESET_FILTER - | FEATURE_SET_EXTRA_FEATURES -) - -FEATURE_FLAGS_AIRPURIFIER_PRO = ( - FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_AUTO_DETECT - | FEATURE_SET_VOLUME -) - -FEATURE_FLAGS_AIRPURIFIER_PRO_V7 = ( - FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_VOLUME -) - -FEATURE_FLAGS_AIRPURIFIER_2S = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL -) - -FEATURE_FLAGS_AIRPURIFIER_3 = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_FAVORITE_LEVEL - | FEATURE_SET_FAN_LEVEL - | FEATURE_SET_LED_BRIGHTNESS -) - -FEATURE_FLAGS_AIRPURIFIER_V3 = ( - FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED -) - -FEATURE_FLAGS_AIRFRESH = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS - | FEATURE_RESET_FILTER - | FEATURE_SET_EXTRA_FEATURES -) - AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) -SERVICE_SCHEMA_LED_BRIGHTNESS = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_BRIGHTNESS): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=2))} -) - -SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_LEVEL): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=17))} -) - -SERVICE_SCHEMA_FAN_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_LEVEL): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3))} -) - -SERVICE_SCHEMA_VOLUME = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_VOLUME): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100))} -) - SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend( {vol.Required(ATTR_FEATURES): cv.positive_int} ) SERVICE_TO_METHOD = { - SERVICE_SET_BUZZER_ON: {"method": "async_set_buzzer_on"}, - SERVICE_SET_BUZZER_OFF: {"method": "async_set_buzzer_off"}, - SERVICE_SET_FAN_LED_ON: {"method": "async_set_led_on"}, - SERVICE_SET_FAN_LED_OFF: {"method": "async_set_led_off"}, - SERVICE_SET_CHILD_LOCK_ON: {"method": "async_set_child_lock_on"}, - SERVICE_SET_CHILD_LOCK_OFF: {"method": "async_set_child_lock_off"}, - SERVICE_SET_AUTO_DETECT_ON: {"method": "async_set_auto_detect_on"}, - SERVICE_SET_AUTO_DETECT_OFF: {"method": "async_set_auto_detect_off"}, - SERVICE_SET_LEARN_MODE_ON: {"method": "async_set_learn_mode_on"}, - SERVICE_SET_LEARN_MODE_OFF: {"method": "async_set_learn_mode_off"}, SERVICE_RESET_FILTER: {"method": "async_reset_filter"}, - SERVICE_SET_LED_BRIGHTNESS: { - "method": "async_set_led_brightness", - "schema": SERVICE_SCHEMA_LED_BRIGHTNESS, - }, - SERVICE_SET_FAVORITE_LEVEL: { - "method": "async_set_favorite_level", - "schema": SERVICE_SCHEMA_FAVORITE_LEVEL, - }, - SERVICE_SET_FAN_LEVEL: { - "method": "async_set_fan_level", - "schema": SERVICE_SCHEMA_FAN_LEVEL, - }, - SERVICE_SET_VOLUME: {"method": "async_set_volume", "schema": SERVICE_SCHEMA_VOLUME}, SERVICE_SET_EXTRA_FEATURES: { "method": "async_set_extra_features", "schema": SERVICE_SCHEMA_EXTRA_FEATURES, }, } - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Fan 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, - ) - ) +FAN_DIRECTIONS_MAP = { + "forward": "right", + "reverse": "left", +} async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Fan from a config entry.""" entities = [] - if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = {} + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return - host = config_entry.data[CONF_HOST] - token = config_entry.data[CONF_TOKEN] - name = config_entry.title - model = config_entry.data[CONF_MODEL] - unique_id = config_entry.unique_id + hass.data.setdefault(DATA_KEY, {}) - _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + name = config_entry.title + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id + 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: - air_purifier = AirPurifierMiot(host, token) - entity = XiaomiAirPurifierMiot( - name, air_purifier, config_entry, unique_id, allowed_failures=2 - ) - elif model.startswith("zhimi.airpurifier."): - air_purifier = AirPurifier(host, token) - entity = XiaomiAirPurifier(name, air_purifier, config_entry, unique_id) - elif model.startswith("zhimi.airfresh."): - air_fresh = AirFresh(host, token) - entity = XiaomiAirFresh(name, air_fresh, config_entry, unique_id) + if model in MODELS_PURIFIER_MIOT: + entity = XiaomiAirPurifierMiot( + name, + device, + config_entry, + unique_id, + coordinator, + ) + elif model.startswith("zhimi.airpurifier."): + entity = XiaomiAirPurifier(name, device, config_entry, unique_id, coordinator) + elif model.startswith("zhimi.airfresh."): + entity = XiaomiAirFresh(name, device, config_entry, unique_id, coordinator) + elif model == MODEL_FAN_P5: + entity = XiaomiFanP5(name, device, config_entry, unique_id, coordinator) + elif model in MODELS_FAN_MIIO: + entity = XiaomiFan(name, device, config_entry, unique_id, coordinator) + else: + return + + hass.data[DATA_KEY][unique_id] = entity + + entities.append(entity) + + async def async_service_handler(service): + """Map services to methods on XiaomiAirPurifier.""" + method = SERVICE_TO_METHOD[service.service] + params = { + key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID + } + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + filtered_entities = [ + entity + for entity in hass.data[DATA_KEY].values() + if entity.entity_id in entity_ids + ] else: - _LOGGER.error( - "Unsupported device found! Please create an issue at " - "https://github.com/syssi/xiaomi_airpurifier/issues " - "and provide the following data: %s", - model, - ) - return + filtered_entities = hass.data[DATA_KEY].values() - hass.data[DATA_KEY][host] = entity - entities.append(entity) + update_tasks = [] - async def async_service_handler(service): - """Map services to methods on XiaomiAirPurifier.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value - for key, value in service.data.items() - if key != ATTR_ENTITY_ID - } - entity_ids = service.data.get(ATTR_ENTITY_ID) - if entity_ids: - entities = [ - entity - for entity in hass.data[DATA_KEY].values() - if entity.entity_id in entity_ids - ] - else: - entities = hass.data[DATA_KEY].values() - - update_tasks = [] - - for entity in entities: - entity_method = getattr(entity, method["method"], None) - if not entity_method: - continue - await entity_method(**params) - update_tasks.append( - hass.async_create_task(entity.async_update_ha_state(True)) - ) - - if update_tasks: - await asyncio.wait(update_tasks) - - for air_purifier_service, method in SERVICE_TO_METHOD.items(): - schema = method.get("schema", AIRPURIFIER_SERVICE_SCHEMA) - hass.services.async_register( - DOMAIN, air_purifier_service, async_service_handler, schema=schema + for entity in filtered_entities: + entity_method = getattr(entity, method["method"], None) + if not entity_method: + continue + await entity_method(**params) + update_tasks.append( + hass.async_create_task(entity.async_update_ha_state(True)) ) - async_add_entities(entities, update_before_add=True) + if update_tasks: + await asyncio.wait(update_tasks) + + for air_purifier_service, method in SERVICE_TO_METHOD.items(): + schema = method.get("schema", AIRPURIFIER_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, air_purifier_service, async_service_handler, schema=schema + ) + + async_add_entities(entities) -class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): +class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Representation of a generic Xiaomi device.""" - def __init__(self, name, device, entry, unique_id): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the generic Xiaomi device.""" - super().__init__(name, device, entry, unique_id) + 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._device_features = FEATURE_SET_CHILD_LOCK - self._skip_update = False + self._device_features = 0 self._supported_features = 0 self._speed_count = 100 self._preset_modes = [] - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [] @property def supported_features(self): """Flag supported features.""" return self._supported_features - # the speed_list attribute is deprecated, support will end with release 2021.7 - @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return self._speed_list - @property def speed_count(self): """Return the number of speeds of the fan supported.""" @@ -555,11 +298,6 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): """Return the percentage based speed of the fan.""" return None - @property - def should_poll(self): - """Poll the device.""" - return True - @property def available(self): """Return true when state is known.""" @@ -583,22 +321,20 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): return value - async def _try_command(self, mask_error, func, *args, **kwargs): - """Call a miio device command handling error messages.""" - try: - result = await self.hass.async_add_executor_job( - partial(func, *args, **kwargs) - ) - - _LOGGER.debug("Response received from miio device: %s", result) - - return result == SUCCESS - except DeviceException as exc: - if self._available: - _LOGGER.error(mask_error, exc) - self._available = False - - return False + @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 @@ -619,9 +355,6 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): "Turning the miio device on failed.", self._device.on ) - # Remove the async_set_speed call is async_set_percentage and async_set_preset_modes have been implemented - if speed: - await self.async_set_speed(speed) # If operation mode was set the device must not be turned on. if percentage: await self.async_set_percentage(percentage) @@ -630,7 +363,7 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): if result: self._state = True - self._skip_update = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" @@ -640,51 +373,7 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): if result: self._state = False - self._skip_update = True - - async def async_set_buzzer_on(self): - """Turn the buzzer on.""" - if self._device_features & FEATURE_SET_BUZZER == 0: - return - - await self._try_command( - "Turning the buzzer of the miio device on failed.", - self._device.set_buzzer, - True, - ) - - async def async_set_buzzer_off(self): - """Turn the buzzer off.""" - if self._device_features & FEATURE_SET_BUZZER == 0: - return - - await self._try_command( - "Turning the buzzer of the miio device off failed.", - self._device.set_buzzer, - False, - ) - - async def async_set_child_lock_on(self): - """Turn the child lock on.""" - if self._device_features & FEATURE_SET_CHILD_LOCK == 0: - return - - await self._try_command( - "Turning the child lock of the miio device on failed.", - self._device.set_child_lock, - True, - ) - - async def async_set_child_lock_off(self): - """Turn the child lock off.""" - if self._device_features & FEATURE_SET_CHILD_LOCK == 0: - return - - await self._try_command( - "Turning the child lock of the miio device off failed.", - self._device.set_child_lock, - False, - ) + self.async_write_ha_state() class XiaomiAirPurifier(XiaomiGenericDevice): @@ -706,113 +395,52 @@ class XiaomiAirPurifier(XiaomiGenericDevice): REVERSE_SPEED_MODE_MAPPING = {v: k for k, v in SPEED_MODE_MAPPING.items()} - def __init__(self, name, device, entry, unique_id, allowed_failures=0): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the plug switch.""" - super().__init__(name, device, entry, unique_id) - self._allowed_failures = allowed_failures - self._failure = 0 + super().__init__(name, device, entry, unique_id, coordinator) if self._model == MODEL_AIRPURIFIER_PRO: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only self._preset_modes = PRESET_MODES_AIRPURIFIER_PRO_V7 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO_V7 elif self._model in [MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H]: self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON self._preset_modes = PRESET_MODES_AIRPURIFIER_2S self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_2S elif self._model in MODELS_PURIFIER_MIOT: - self._device_features = FEATURE_FLAGS_AIRPURIFIER_3 - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_3 - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only - self._preset_modes = PRESET_MODES_AIRPURIFIER_3 + 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 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_3 elif self._model == MODEL_AIRPURIFIER_V3: self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 - # SUPPORT_SET_SPEED was disabled - # the device supports preset_modes only self._preset_modes = PRESET_MODES_AIRPURIFIER_V3 self._supported_features = SUPPORT_PRESET_MODE self._speed_count = 1 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER_V3 else: - self._device_features = FEATURE_FLAGS_AIRPURIFIER + 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 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [] self._state_attrs.update( {attribute: None for attribute in self._available_attributes} ) - - async def async_update(self): - """Fetch state from the device.""" - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = await self.hass.async_add_executor_job(self._device.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._state_attrs.update( - { - key: self._extract_value_from_attribute(state, value) - for key, value in self._available_attributes.items() - } - ) - - self._failure = 0 - - except DeviceException as ex: - self._failure += 1 - if self._failure < self._allowed_failures: - _LOGGER.info( - "Got exception while fetching the state: %s, failure: %d", - ex, - self._failure, - ) - else: - if self._available: - self._available = False - _LOGGER.error( - "Got exception while fetching the state: %s, failure: %d", - ex, - self._failure, - ) + self._mode = self._state_attrs.get(ATTR_MODE) + self._fan_level = getattr(self.coordinator.data, ATTR_FAN_LEVEL, None) @property def preset_mode(self): @@ -835,20 +463,15 @@ class XiaomiAirPurifier(XiaomiGenericDevice): return None - # the speed attribute is deprecated, support will end with release 2021.7 - @property - def speed(self): - """Return the current speed.""" - if self._state: - return AirpurifierOperationMode(self._state_attrs[ATTR_MODE]).name - - return None - async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan. This method is a coroutine. """ + if percentage == 0: + await self.async_turn_off() + return + speed_mode = math.ceil( percentage_to_ranged_value((1, self._speed_count), percentage) ) @@ -873,129 +496,6 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self.PRESET_MODE_MAPPING[preset_mode], ) - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if self.supported_features & SUPPORT_SET_SPEED == 0: - return - - _LOGGER.debug("Setting the operation mode to: %s", speed) - - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirpurifierOperationMode[speed.title()], - ) - - async def async_set_led_on(self): - """Turn the led on.""" - if self._device_features & FEATURE_SET_LED == 0: - return - - await self._try_command( - "Turning the led of the miio device off failed.", self._device.set_led, True - ) - - async def async_set_led_off(self): - """Turn the led off.""" - if self._device_features & FEATURE_SET_LED == 0: - return - - await self._try_command( - "Turning the led of the miio device off failed.", - self._device.set_led, - False, - ) - - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirpurifierLedBrightness(brightness), - ) - - async def async_set_favorite_level(self, level: int = 1): - """Set the favorite level.""" - if self._device_features & FEATURE_SET_FAVORITE_LEVEL == 0: - return - - await self._try_command( - "Setting the favorite level of the miio device failed.", - self._device.set_favorite_level, - level, - ) - - async def async_set_fan_level(self, level: int = 1): - """Set the favorite level.""" - if self._device_features & FEATURE_SET_FAN_LEVEL == 0: - return - - await self._try_command( - "Setting the fan level of the miio device failed.", - self._device.set_fan_level, - level, - ) - - async def async_set_auto_detect_on(self): - """Turn the auto detect on.""" - if self._device_features & FEATURE_SET_AUTO_DETECT == 0: - return - - await self._try_command( - "Turning the auto detect of the miio device on failed.", - self._device.set_auto_detect, - True, - ) - - async def async_set_auto_detect_off(self): - """Turn the auto detect off.""" - if self._device_features & FEATURE_SET_AUTO_DETECT == 0: - return - - await self._try_command( - "Turning the auto detect of the miio device off failed.", - self._device.set_auto_detect, - False, - ) - - async def async_set_learn_mode_on(self): - """Turn the learn mode on.""" - if self._device_features & FEATURE_SET_LEARN_MODE == 0: - return - - await self._try_command( - "Turning the learn mode of the miio device on failed.", - self._device.set_learn_mode, - True, - ) - - async def async_set_learn_mode_off(self): - """Turn the learn mode off.""" - if self._device_features & FEATURE_SET_LEARN_MODE == 0: - return - - await self._try_command( - "Turning the learn mode of the miio device off failed.", - self._device.set_learn_mode, - False, - ) - - async def async_set_volume(self, volume: int = 50): - """Set the sound volume.""" - if self._device_features & FEATURE_SET_VOLUME == 0: - return - - await self._try_command( - "Setting the sound volume of the miio device failed.", - self._device.set_volume, - volume, - ) - async def async_set_extra_features(self, features: int = 1): """Set the extra features.""" if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0: @@ -1031,9 +531,10 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): @property def percentage(self): """Return the current percentage based speed.""" + if self._fan_level is None: + return None if self._state: - fan_level = self._state_attrs[ATTR_FAN_LEVEL] - return ranged_value_to_percentage((1, 3), fan_level) + return ranged_value_to_percentage((1, 3), self._fan_level) return None @@ -1041,34 +542,30 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): def preset_mode(self): """Get the active preset mode.""" if self._state: - preset_mode = AirpurifierMiotOperationMode( - self._state_attrs[ATTR_MODE] - ).name + preset_mode = AirpurifierMiotOperationMode(self._mode).name return preset_mode if preset_mode in self._preset_modes else None return None - # the speed attribute is deprecated, support will end with release 2021.7 - @property - def speed(self): - """Return the current speed.""" - if self._state: - return AirpurifierMiotOperationMode(self._state_attrs[ATTR_MODE]).name - - return None - async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan. This method is a coroutine. """ + if percentage == 0: + await self.async_turn_off() + return + fan_level = math.ceil(percentage_to_ranged_value((1, 3), percentage)) - if fan_level: - await self._try_command( - "Setting fan level of the miio device failed.", - self._device.set_fan_level, - fan_level, - ) + if not fan_level: + return + if await self._try_command( + "Setting fan level of the miio device failed.", + self._device.set_fan_level, + fan_level, + ): + 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. @@ -1078,37 +575,13 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, self.PRESET_MODE_MAPPING[preset_mode], - ) - - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if self.supported_features & SUPPORT_SET_SPEED == 0: - return - - _LOGGER.debug("Setting the operation mode to: %s", speed) - - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirpurifierMiotOperationMode[speed.title()], - ) - - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirpurifierMiotLedBrightness(brightness), - ) + ): + self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self.async_write_ha_state() class XiaomiAirFresh(XiaomiGenericDevice): @@ -1128,51 +601,25 @@ class XiaomiAirFresh(XiaomiGenericDevice): "Interval": AirfreshOperationMode.Interval, } - def __init__(self, name, device, entry, unique_id): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the miio device.""" - super().__init__(name, device, entry, unique_id) + super().__init__(name, device, entry, unique_id, coordinator) self._device_features = FEATURE_FLAGS_AIRFRESH self._available_attributes = AVAILABLE_ATTRIBUTES_AIRFRESH - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRFRESH self._speed_count = 4 self._preset_modes = PRESET_MODES_AIRFRESH self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE self._state_attrs.update( {attribute: None for attribute in self._available_attributes} ) - - async def async_update(self): - """Fetch state from the device.""" - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = await self.hass.async_add_executor_job(self._device.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._state_attrs.update( - { - key: self._extract_value_from_attribute(state, value) - for key, value in self._available_attributes.items() - } - ) - - except DeviceException as ex: - if self._available: - self._available = False - _LOGGER.error("Got exception while fetching the state: %s", ex) + self._mode = self._state_attrs.get(ATTR_MODE) @property def preset_mode(self): """Get the active preset mode.""" if self._state: - preset_mode = AirfreshOperationMode(self._state_attrs[ATTR_MODE]).name + preset_mode = AirfreshOperationMode(self._mode).name return preset_mode if preset_mode in self._preset_modes else None return None @@ -1181,7 +628,7 @@ class XiaomiAirFresh(XiaomiGenericDevice): def percentage(self): """Return the current percentage based speed.""" if self._state: - mode = AirfreshOperationMode(self._state_attrs[ATTR_MODE]) + mode = AirfreshOperationMode(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] @@ -1189,15 +636,6 @@ class XiaomiAirFresh(XiaomiGenericDevice): return None - # the speed attribute is deprecated, support will end with release 2021.7 - @property - def speed(self): - """Return the current speed.""" - if self._state: - return AirfreshOperationMode(self._state_attrs[ATTR_MODE]).name - - return None - async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan. @@ -1207,11 +645,15 @@ class XiaomiAirFresh(XiaomiGenericDevice): percentage_to_ranged_value((1, self._speed_count), percentage) ) if speed_mode: - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, AirfreshOperationMode(self.SPEED_MODE_MAPPING[speed_mode]), - ) + ): + self._mode = AirfreshOperationMode( + self.SPEED_MODE_MAPPING[speed_mode] + ).value + self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. @@ -1221,57 +663,13 @@ class XiaomiAirFresh(XiaomiGenericDevice): if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, self.PRESET_MODE_MAPPING[preset_mode], - ) - - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if self.supported_features & SUPPORT_SET_SPEED == 0: - return - - _LOGGER.debug("Setting the operation mode to: %s", speed) - - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirfreshOperationMode[speed.title()], - ) - - async def async_set_led_on(self): - """Turn the led on.""" - if self._device_features & FEATURE_SET_LED == 0: - return - - await self._try_command( - "Turning the led of the miio device off failed.", self._device.set_led, True - ) - - async def async_set_led_off(self): - """Turn the led off.""" - if self._device_features & FEATURE_SET_LED == 0: - return - - await self._try_command( - "Turning the led of the miio device off failed.", - self._device.set_led, - False, - ) - - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirfreshLedBrightness(brightness), - ) + ): + self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self.async_write_ha_state() async def async_set_extra_features(self, features: int = 1): """Set the extra features.""" @@ -1293,3 +691,184 @@ class XiaomiAirFresh(XiaomiGenericDevice): "Resetting the filter lifetime of the miio device failed.", self._device.reset_filter, ) + + +class XiaomiFan(XiaomiGenericDevice): + """Representation of a Xiaomi Fan.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the plug switch.""" + 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] + 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 + ) + self._preset_mode = None + self._oscillating = None + self._percentage = None + + @property + def preset_mode(self): + """Get the active preset mode.""" + return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL + + @property + def percentage(self): + """Return the current speed as a percentage.""" + return self._percentage + + @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 + 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 + 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 + + if preset_mode == ATTR_MODE_NATURE: + await self._try_command( + "Setting natural fan speed percentage of the miio device failed.", + self._device.set_natural_speed, + self._percentage, + ) + else: + await self._try_command( + "Setting direct fan speed percentage of the miio device failed.", + self._device.set_direct_speed, + self._percentage, + ) + + 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 + + if self._nature_mode: + await self._try_command( + "Setting fan speed percentage of the miio device failed.", + self._device.set_natural_speed, + percentage, + ) + else: + await self._try_command( + "Setting fan speed percentage of the miio device failed.", + self._device.set_direct_speed, + percentage, + ) + self._percentage = percentage + + if not self.is_on: + await self.async_turn_on() + 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): + """Representation of a Xiaomi Fan P5.""" + + @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._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.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, + FanOperationMode[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() diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index 17f42f4bffa..8b7a5c77a17 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -117,7 +117,7 @@ class ConnectXiaomiGateway: miio_cloud = MiCloud(self._cloud_username, self._cloud_password) if not miio_cloud.login(): raise ConfigEntryAuthFailed( - "Could not login to Xioami Miio Cloud, check the credentials" + "Could not login to Xiaomi Miio Cloud, check the credentials" ) devices_raw = miio_cloud.get_devices(self._cloud_country) self._gateway_device.get_devices_from_dict(devices_raw) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index aee2c237066..aa26faae2b3 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -5,6 +5,7 @@ import math from miio.airhumidifier import OperationMode as AirhumidifierOperationMode from miio.airhumidifier_miot import OperationMode as AirhumidifierMiotOperationMode +from miio.airhumidifier_mjjsq import OperationMode as AirhumidifierMjjsqOperationMode from homeassistant.components.humidifier import HumidifierEntity from homeassistant.components.humidifier.const import ( @@ -28,6 +29,7 @@ from .const import ( MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, MODELS_HUMIDIFIER_MIOT, + MODELS_HUMIDIFIER_MJJSQ, ) from .device import XiaomiCoordinatedMiioEntity @@ -41,6 +43,23 @@ AVAILABLE_ATTRIBUTES = { ATTR_TARGET_HUMIDITY: "target_humidity", } +AVAILABLE_MODES_CA1_CB1 = [ + mode.name + for mode in AirhumidifierOperationMode + if mode is not AirhumidifierOperationMode.Strong +] +AVAILABLE_MODES_CA4 = [mode.name for mode in AirhumidifierMiotOperationMode] +AVAILABLE_MODES_MJJSQ = [ + mode.name + for mode in AirhumidifierMjjsqOperationMode + if mode is not AirhumidifierMjjsqOperationMode.WetAndProtect +] +AVAILABLE_MODES_OTHER = [ + mode.name + for mode in AirhumidifierOperationMode + if mode is not AirhumidifierOperationMode.Auto +] + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Humidifier from a config entry.""" @@ -62,6 +81,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): unique_id, coordinator, ) + elif model in MODELS_HUMIDIFIER_MJJSQ: + air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + entity = XiaomiAirHumidifierMjjsq( + name, + air_humidifier, + config_entry, + unique_id, + coordinator, + ) else: air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] entity = XiaomiAirHumidifier( @@ -169,28 +197,22 @@ class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): """Initialize the plug switch.""" super().__init__(name, device, entry, unique_id, coordinator) if self._model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: - self._available_modes = [] - self._available_modes = [ - mode.name - for mode in AirhumidifierOperationMode - if mode is not AirhumidifierOperationMode.Strong - ] + self._available_modes = AVAILABLE_MODES_CA1_CB1 self._min_humidity = 30 self._max_humidity = 80 self._humidity_steps = 10 elif self._model in [MODEL_AIRHUMIDIFIER_CA4]: - self._available_modes = [ - mode.name for mode in AirhumidifierMiotOperationMode - ] + self._available_modes = AVAILABLE_MODES_CA4 self._min_humidity = 30 self._max_humidity = 80 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._humidity_steps = 10 else: - self._available_modes = [ - mode.name - for mode in AirhumidifierOperationMode - if mode is not AirhumidifierOperationMode.Auto - ] + self._available_modes = AVAILABLE_MODES_OTHER self._min_humidity = 30 self._max_humidity = 80 self._humidity_steps = 10 @@ -364,3 +386,75 @@ class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): ): self._mode = self.REVERSE_MODE_MAPPING[mode].value self.async_write_ha_state() + + +class XiaomiAirHumidifierMjjsq(XiaomiAirHumidifier): + """Representation of a Xiaomi Air MJJSQ Humidifier.""" + + MODE_MAPPING = { + "Low": AirhumidifierMjjsqOperationMode.Low, + "Medium": AirhumidifierMjjsqOperationMode.Medium, + "High": AirhumidifierMjjsqOperationMode.High, + "Humidity": AirhumidifierMjjsqOperationMode.Humidity, + } + + @property + def mode(self): + """Return the current mode.""" + return AirhumidifierMjjsqOperationMode(self._mode).name + + @property + def target_humidity(self): + """Return the target humidity.""" + if self._state: + if ( + AirhumidifierMjjsqOperationMode(self._mode) + == AirhumidifierMjjsqOperationMode.Humidity + ): + return self._target_humidity + return None + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity of the humidifier and set the mode to Humidity.""" + target_humidity = self.translate_humidity(humidity) + if not target_humidity: + return + + _LOGGER.debug("Setting the humidity to: %s", target_humidity) + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_target_humidity, + target_humidity, + ): + self._target_humidity = target_humidity + if ( + self.supported_features & SUPPORT_MODES == 0 + or AirhumidifierMjjsqOperationMode(self._attributes[ATTR_MODE]) + == AirhumidifierMjjsqOperationMode.Humidity + ): + self.async_write_ha_state() + return + _LOGGER.debug("Setting the operation mode to: Humidity") + if await self._try_command( + "Setting operation mode of the miio device to MODE_HUMIDITY failed.", + self._device.set_mode, + AirhumidifierMjjsqOperationMode.Humidity, + ): + self._mode = 3 + self.async_write_ha_state() + + async def async_set_mode(self, mode: str) -> None: + """Set the mode of the fan.""" + if mode not in self.MODE_MAPPING: + _LOGGER.warning("Mode %s is not a valid operation mode", mode) + return + + _LOGGER.debug("Setting the operation mode to: %s", mode) + if self._state: + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, + self.MODE_MAPPING[mode], + ): + self._mode = self.MODE_MAPPING[mode].value + self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 6025ae047c6..b916de899b9 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -19,14 +19,12 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, LightEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.util import color, dt @@ -37,7 +35,6 @@ from .const import ( CONF_MODEL, DOMAIN, KEY_COORDINATOR, - MODELS_LIGHT, MODELS_LIGHT_BULB, MODELS_LIGHT_CEILING, MODELS_LIGHT_EYECARE, @@ -60,15 +57,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Philips Light" DATA_KEY = "light.xiaomi_miio" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MODEL): vol.In(MODELS_LIGHT), - } -) - # The light does not accept cct values < 1 CCT_MIN = 1 CCT_MAX = 100 @@ -120,21 +108,6 @@ SERVICE_TO_METHOD = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Light 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, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi light from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 1f37d624b95..18aa7f75ce1 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,8 +3,8 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.6"], - "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], + "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.7"], + "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" } diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 6855faa6391..af5f29306a0 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -1,8 +1,11 @@ """Motor speed support for Xiaomi Mi Air Humidifier.""" +from __future__ import annotations + from dataclasses import dataclass from enum import Enum -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import DEGREE, TIME_MINUTES from homeassistant.core import callback from .const import ( @@ -10,42 +13,155 @@ from .const import ( CONF_FLOW_TYPE, CONF_MODEL, DOMAIN, + FEATURE_FLAGS_AIRFRESH, + FEATURE_FLAGS_AIRHUMIDIFIER_CA4, + FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_MIIO, + FEATURE_FLAGS_AIRPURIFIER_MIOT, + FEATURE_FLAGS_AIRPURIFIER_PRO, + FEATURE_FLAGS_AIRPURIFIER_PRO_V7, + FEATURE_FLAGS_AIRPURIFIER_V1, + FEATURE_FLAGS_AIRPURIFIER_V3, + FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_P5, + FEATURE_SET_DELAY_OFF_COUNTDOWN, + FEATURE_SET_FAN_LEVEL, + FEATURE_SET_FAVORITE_LEVEL, FEATURE_SET_MOTOR_SPEED, + FEATURE_SET_OSCILLATION_ANGLE, + FEATURE_SET_OSCILLATION_ANGLE_MAX_140, + FEATURE_SET_VOLUME, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRFRESH_VA2, + MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_V1, + MODEL_AIRPURIFIER_V3, + MODEL_FAN_P5, + MODEL_FAN_SA1, + MODEL_FAN_V2, + MODEL_FAN_V3, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, + MODELS_PURIFIER_MIIO, + MODELS_PURIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity +ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown" +ATTR_FAN_LEVEL = "fan_level" +ATTR_FAVORITE_LEVEL = "favorite_level" ATTR_MOTOR_SPEED = "motor_speed" +ATTR_OSCILLATION_ANGLE = "angle" +ATTR_VOLUME = "volume" @dataclass -class NumberType: - """Class that holds device specific info for a xiaomi aqara or humidifier number controller types.""" +class XiaomiMiioNumberDescription(NumberEntityDescription): + """A class that describes number entities.""" - name: str = None - short_name: str = None - unit_of_measurement: str = None - icon: str = None - device_class: str = None - min: float = None - max: float = None - step: float = None + min_value: float | None = None + max_value: float | None = None + step: float | None = None available_with_device_off: bool = True + method: str | None = None NUMBER_TYPES = { - FEATURE_SET_MOTOR_SPEED: NumberType( + FEATURE_SET_MOTOR_SPEED: XiaomiMiioNumberDescription( + key=ATTR_MOTOR_SPEED, name="Motor Speed", icon="mdi:fast-forward-outline", - short_name=ATTR_MOTOR_SPEED, unit_of_measurement="rpm", - min=200, - max=2000, + min_value=200, + max_value=2000, step=10, available_with_device_off=False, + method="async_set_motor_speed", ), + FEATURE_SET_FAVORITE_LEVEL: XiaomiMiioNumberDescription( + key=ATTR_FAVORITE_LEVEL, + name="Favorite Level", + icon="mdi:star-cog", + min_value=0, + max_value=17, + step=1, + method="async_set_favorite_level", + ), + FEATURE_SET_FAN_LEVEL: XiaomiMiioNumberDescription( + key=ATTR_FAN_LEVEL, + name="Fan Level", + icon="mdi:fan", + min_value=1, + max_value=3, + step=1, + method="async_set_fan_level", + ), + FEATURE_SET_VOLUME: XiaomiMiioNumberDescription( + key=ATTR_VOLUME, + name="Volume", + icon="mdi:volume-high", + min_value=0, + max_value=100, + step=1, + method="async_set_volume", + ), + FEATURE_SET_OSCILLATION_ANGLE: XiaomiMiioNumberDescription( + key=ATTR_OSCILLATION_ANGLE, + name="Oscillation Angle", + icon="mdi:angle-acute", + unit_of_measurement=DEGREE, + min_value=1, + max_value=120, + 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", + icon="mdi:fan-off", + unit_of_measurement=TIME_MINUTES, + min_value=0, + max_value=480, + step=1, + method="async_set_delay_off_countdown", + ), +} + +MODEL_TO_FEATURES_MAP = { + MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH, + MODEL_AIRHUMIDIFIER_CA1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + 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_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_P5: FEATURE_FLAGS_FAN_P5, + 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, } @@ -56,41 +172,46 @@ async def async_setup_entry(hass, config_entry, async_add_entities): return model = config_entry.data[CONF_MODEL] device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - if model not in [MODEL_AIRHUMIDIFIER_CA4]: + if model in MODEL_TO_FEATURES_MAP: + features = MODEL_TO_FEATURES_MAP[model] + elif model in MODELS_PURIFIER_MIIO: + features = FEATURE_FLAGS_AIRPURIFIER_MIIO + elif model in MODELS_PURIFIER_MIOT: + features = FEATURE_FLAGS_AIRPURIFIER_MIOT + else: return - for number in NUMBER_TYPES.values(): - entities.append( - XiaomiAirHumidifierNumber( - f"{config_entry.title} {number.name}", - device, - config_entry, - f"{number.short_name}_{config_entry.unique_id}", - number, - coordinator, + for feature, description in NUMBER_TYPES.items(): + if feature & features: + entities.append( + XiaomiNumberEntity( + f"{config_entry.title} {description.name}", + device, + config_entry, + f"{description.key}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) ) - ) async_add_entities(entities) -class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): +class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Representation of a generic Xiaomi attribute selector.""" - def __init__(self, name, device, entry, unique_id, number, coordinator): + def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the generic Xiaomi attribute selector.""" super().__init__(name, device, entry, unique_id, coordinator) - self._attr_icon = number.icon - self._attr_unit_of_measurement = number.unit_of_measurement - self._attr_min_value = number.min - self._attr_max_value = number.max - self._attr_step = number.step - self._controller = number + + self._attr_min_value = description.min_value + self._attr_max_value = description.max_value + self._attr_step = description.step self._attr_value = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + coordinator.data, description.key ) + self.entity_description = description @property def available(self): @@ -98,7 +219,7 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): if ( super().available and not self.coordinator.data.is_on - and not self._controller.available_with_device_off + and not self.entity_description.available_with_device_off ): return False return super().available @@ -113,16 +234,8 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): async def async_set_value(self, value): """Set an option of the miio device.""" - if ( - self.min_value - and value < self.min_value - or self.max_value - and value > self.max_value - ): - raise ValueError( - f"Value {value} not a valid {self.name} within the range {self.min_value} - {self.max_value}" - ) - if await self.async_set_motor_speed(value): + method = getattr(self, self.entity_description.method) + if await method(int(value)): self._attr_value = value self.async_write_ha_state() @@ -131,7 +244,7 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): """Fetch state from the device.""" # On state change the device doesn't provide the new state immediately. self._attr_value = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + self.coordinator.data, self.entity_description.key ) self.async_write_ha_state() @@ -142,3 +255,41 @@ class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): self._device.set_speed, motor_speed, ) + + async def async_set_favorite_level(self, level: int = 1): + """Set the favorite level.""" + return await self._try_command( + "Setting the favorite level of the miio device failed.", + self._device.set_favorite_level, + level, + ) + + async def async_set_fan_level(self, level: int = 1): + """Set the fan level.""" + return await self._try_command( + "Setting the favorite level of the miio device failed.", + self._device.set_fan_level, + level, + ) + + async def async_set_volume(self, volume: int = 50): + """Set the volume.""" + return await self._try_command( + "Setting the volume of the miio device failed.", + self._device.set_volume, + volume, + ) + + async def async_set_oscillation_angle(self, angle: int): + """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): + """Set the delay off countdown.""" + return await self._try_command( + "Setting delay off miio device failed.", + self._device.delay_off, + delay_off_countdown * 60, + ) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index c5cee6221fa..b43291dfeef 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -4,8 +4,12 @@ from __future__ import annotations from dataclasses import dataclass from enum import Enum +from miio.airfresh import LedBrightness as AirfreshLedBrightness from miio.airhumidifier import LedBrightness as AirhumidifierLedBrightness from miio.airhumidifier_miot import LedBrightness as AirhumidifierMiotLedBrightness +from miio.airpurifier import LedBrightness as AirpurifierLedBrightness +from miio.airpurifier_miot import LedBrightness as AirpurifierMiotLedBrightness +from miio.fan import LedBrightness as FanLedBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import callback @@ -18,10 +22,18 @@ from .const import ( FEATURE_SET_LED_BRIGHTNESS, KEY_COORDINATOR, KEY_DEVICE, - MODEL_AIRHUMIDIFIER_CA1, - MODEL_AIRHUMIDIFIER_CA4, - MODEL_AIRHUMIDIFIER_CB1, - MODELS_HUMIDIFIER, + MODEL_AIRFRESH_VA2, + MODEL_AIRPURIFIER_M1, + MODEL_AIRPURIFIER_M2, + MODEL_FAN_SA1, + MODEL_FAN_V2, + MODEL_FAN_V3, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, + MODELS_HUMIDIFIER_MIIO, + MODELS_HUMIDIFIER_MIOT, + MODELS_PURIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity @@ -29,10 +41,10 @@ ATTR_LED_BRIGHTNESS = "led_brightness" LED_BRIGHTNESS_MAP = {"Bright": 0, "Dim": 1, "Off": 2} -LED_BRIGHTNESS_MAP_MIOT = {"Bright": 2, "Dim": 1, "Off": 0} +LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT = {"Bright": 2, "Dim": 1, "Off": 0} LED_BRIGHTNESS_REVERSE_MAP = {val: key for key, val in LED_BRIGHTNESS_MAP.items()} -LED_BRIGHTNESS_REVERSE_MAP_MIOT = { - val: key for key, val in LED_BRIGHTNESS_MAP_MIOT.items() +LED_BRIGHTNESS_REVERSE_MAP_HUMIDIFIER_MIOT = { + val: key for key, val in LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT.items() } @@ -63,12 +75,25 @@ 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 in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: + if model in MODELS_HUMIDIFIER_MIIO: entity_class = XiaomiAirHumidifierSelector - elif model in [MODEL_AIRHUMIDIFIER_CA4]: + elif model in MODELS_HUMIDIFIER_MIOT: entity_class = XiaomiAirHumidifierMiotSelector - elif model in MODELS_HUMIDIFIER: - entity_class = XiaomiAirHumidifierSelector + elif model in [MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2]: + entity_class = XiaomiAirPurifierSelector + elif model in MODELS_PURIFIER_MIOT: + entity_class = XiaomiAirPurifierMiotSelector + elif model == MODEL_AIRFRESH_VA2: + entity_class = XiaomiAirFreshSelector + elif model in ( + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, + MODEL_FAN_SA1, + MODEL_FAN_V2, + MODEL_FAN_V3, + ): + entity_class = XiaomiFanSelector else: return @@ -130,10 +155,6 @@ class XiaomiAirHumidifierSelector(XiaomiSelector): async def async_select_option(self, option: str) -> None: """Set an option of the miio device.""" - if option not in self.options: - raise ValueError( - f"Selection '{option}' is not a valid {self.entity_description.name}" - ) await self.async_set_led_brightness(option.title()) @property @@ -158,14 +179,76 @@ class XiaomiAirHumidifierMiotSelector(XiaomiAirHumidifierSelector): @property def led_brightness(self): """Return the current led brightness.""" - return LED_BRIGHTNESS_REVERSE_MAP_MIOT.get(self._current_led_brightness) + return LED_BRIGHTNESS_REVERSE_MAP_HUMIDIFIER_MIOT.get( + self._current_led_brightness + ) - async def async_set_led_brightness(self, brightness: str): + async def async_set_led_brightness(self, brightness: str) -> None: """Set the led brightness.""" if await self._try_command( "Setting the led brightness of the miio device failed.", self._device.set_led_brightness, - AirhumidifierMiotLedBrightness(LED_BRIGHTNESS_MAP_MIOT[brightness]), + AirhumidifierMiotLedBrightness( + LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT[brightness] + ), ): - self._current_led_brightness = LED_BRIGHTNESS_MAP_MIOT[brightness] + self._current_led_brightness = LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT[ + brightness + ] + self.async_write_ha_state() + + +class XiaomiAirPurifierSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Air Purifier (MIIO protocol) selector.""" + + async def async_set_led_brightness(self, brightness: str) -> None: + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirpurifierLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self.async_write_ha_state() + + +class XiaomiAirPurifierMiotSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Air Purifier (MiOT protocol) selector.""" + + async def async_set_led_brightness(self, brightness: str) -> None: + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirpurifierMiotLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self.async_write_ha_state() + + +class XiaomiFanSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Fan (MIIO protocol) selector.""" + + async def async_set_led_brightness(self, brightness: str) -> None: + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + FanLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self.async_write_ha_state() + + +class XiaomiAirFreshSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Air Fresh selector.""" + + async def async_set_led_brightness(self, brightness: str) -> None: + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirfreshLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 9804e0298bc..63535e88a2d 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -1,4 +1,6 @@ """Support for Xiaomi Mi Air Quality Monitor (PM2.5) and Humidifier.""" +from __future__ import annotations + from dataclasses import dataclass from enum import Enum import logging @@ -8,34 +10,40 @@ from miio.gateway.gateway import ( GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, GATEWAY_MODEL_AC_V3, + GATEWAY_MODEL_AQARA, GATEWAY_MODEL_EU, GatewayException, ) -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, CONF_HOST, - CONF_NAME, CONF_TOKEN, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO2, + DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + LIGHT_LUX, PERCENTAGE, POWER_WATT, PRESSURE_HPA, TEMP_CELSIUS, + TIME_HOURS, + VOLUME_CUBIC_METERS, ) -import homeassistant.helpers.config_validation as cv from .const import ( CONF_DEVICE, @@ -45,9 +53,24 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRFRESH_VA2, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_V2, + MODEL_AIRPURIFIER_V3, + MODEL_FAN_P5, + MODEL_FAN_V2, + MODEL_FAN_V3, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, + MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, + MODELS_HUMIDIFIER_MJJSQ, + MODELS_PURIFIER_MIIO, + MODELS_PURIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity from .gateway import XiaomiGatewayDevice @@ -57,115 +80,260 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Miio Sensor" UNIT_LUMEN = "lm" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - ATTR_ACTUAL_SPEED = "actual_speed" +ATTR_AIR_QUALITY = "air_quality" +ATTR_AQI = "aqi" +ATTR_BATTERY = "battery" +ATTR_CARBON_DIOXIDE = "co2" ATTR_CHARGING = "charging" ATTR_DISPLAY_CLOCK = "display_clock" +ATTR_FILTER_LIFE_REMAINING = "filter_life_remaining" +ATTR_FILTER_HOURS_USED = "filter_hours_used" +ATTR_FILTER_USE = "filter_use" ATTR_HUMIDITY = "humidity" +ATTR_ILLUMINANCE = "illuminance" +ATTR_ILLUMINANCE_LUX = "illuminance_lux" +ATTR_LOAD_POWER = "load_power" +ATTR_MOTOR2_SPEED = "motor2_speed" ATTR_MOTOR_SPEED = "motor_speed" ATTR_NIGHT_MODE = "night_mode" ATTR_NIGHT_TIME_BEGIN = "night_time_begin" ATTR_NIGHT_TIME_END = "night_time_end" +ATTR_PM25 = "pm25" ATTR_POWER = "power" +ATTR_PRESSURE = "pressure" +ATTR_PURIFY_VOLUME = "purify_volume" ATTR_SENSOR_STATE = "sensor_state" ATTR_WATER_LEVEL = "water_level" @dataclass -class SensorType: +class XiaomiMiioSensorDescription(SensorEntityDescription): """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" - unit: str = None - icon: str = None - device_class: str = None - state_class: str = None - valid_min_value: float = None - valid_max_value: float = None + attributes: tuple = () SENSOR_TYPES = { - "temperature": SensorType( - unit=TEMP_CELSIUS, + ATTR_TEMPERATURE: XiaomiMiioSensorDescription( + key=ATTR_TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), - "humidity": SensorType( - unit=PERCENTAGE, + ATTR_HUMIDITY: XiaomiMiioSensorDescription( + key=ATTR_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), - "pressure": SensorType( - unit=PRESSURE_HPA, + ATTR_PRESSURE: XiaomiMiioSensorDescription( + key=ATTR_PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), - "load_power": SensorType( - unit=POWER_WATT, + ATTR_LOAD_POWER: XiaomiMiioSensorDescription( + key=ATTR_LOAD_POWER, + name="Load Power", + native_unit_of_measurement=POWER_WATT, device_class=DEVICE_CLASS_POWER, ), - "water_level": SensorType( - unit=PERCENTAGE, + ATTR_WATER_LEVEL: XiaomiMiioSensorDescription( + key=ATTR_WATER_LEVEL, + name="Water Level", + native_unit_of_measurement=PERCENTAGE, icon="mdi:water-check", state_class=STATE_CLASS_MEASUREMENT, - valid_min_value=0.0, - valid_max_value=100.0, ), - "actual_speed": SensorType( - unit="rpm", + ATTR_ACTUAL_SPEED: XiaomiMiioSensorDescription( + key=ATTR_ACTUAL_SPEED, + name="Actual Speed", + native_unit_of_measurement="rpm", icon="mdi:fast-forward", state_class=STATE_CLASS_MEASUREMENT, - valid_min_value=200.0, - valid_max_value=2000.0, ), - "motor_speed": SensorType( - unit="rpm", + ATTR_MOTOR_SPEED: XiaomiMiioSensorDescription( + key=ATTR_MOTOR_SPEED, + name="Motor Speed", + native_unit_of_measurement="rpm", icon="mdi:fast-forward", state_class=STATE_CLASS_MEASUREMENT, - valid_min_value=200.0, - valid_max_value=2000.0, + ), + ATTR_MOTOR2_SPEED: XiaomiMiioSensorDescription( + key=ATTR_MOTOR2_SPEED, + name="Second Motor Speed", + native_unit_of_measurement="rpm", + icon="mdi:fast-forward", + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_ILLUMINANCE: XiaomiMiioSensorDescription( + key=ATTR_ILLUMINANCE, + name="Illuminance", + native_unit_of_measurement=UNIT_LUMEN, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_ILLUMINANCE_LUX: XiaomiMiioSensorDescription( + key=ATTR_ILLUMINANCE, + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_AIR_QUALITY: XiaomiMiioSensorDescription( + key=ATTR_AIR_QUALITY, + native_unit_of_measurement="AQI", + icon="mdi:cloud", + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_PM25: XiaomiMiioSensorDescription( + key=ATTR_AQI, + name="PM2.5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:blur", + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( + key=ATTR_FILTER_LIFE_REMAINING, + name="Filter Life Remaining", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:air-filter", + state_class=STATE_CLASS_MEASUREMENT, + attributes=("filter_type",), + ), + ATTR_FILTER_USE: XiaomiMiioSensorDescription( + key=ATTR_FILTER_HOURS_USED, + name="Filter Use", + native_unit_of_measurement=TIME_HOURS, + icon="mdi:clock-outline", + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_CARBON_DIOXIDE: XiaomiMiioSensorDescription( + key=ATTR_CARBON_DIOXIDE, + name="Carbon Dioxide", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + ), + ATTR_PURIFY_VOLUME: XiaomiMiioSensorDescription( + key=ATTR_PURIFY_VOLUME, + name="Purify Volume", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ATTR_BATTERY: XiaomiMiioSensorDescription( + key=ATTR_BATTERY, + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, ), } -HUMIDIFIER_SENSORS = { - ATTR_HUMIDITY: "humidity", - ATTR_TEMPERATURE: "temperature", +HUMIDIFIER_MIIO_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE, ATTR_WATER_LEVEL) +HUMIDIFIER_CA1_CB1_SENSORS = ( + ATTR_HUMIDITY, + ATTR_TEMPERATURE, + ATTR_MOTOR_SPEED, + ATTR_WATER_LEVEL, +) +HUMIDIFIER_MIOT_SENSORS = ( + ATTR_ACTUAL_SPEED, + ATTR_HUMIDITY, + ATTR_TEMPERATURE, + ATTR_WATER_LEVEL, +) +HUMIDIFIER_MJJSQ_SENSORS = (ATTR_HUMIDITY, ATTR_TEMPERATURE) + +PURIFIER_MIIO_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, +) +PURIFIER_MIOT_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_PURIFY_VOLUME, + ATTR_TEMPERATURE, +) +PURIFIER_V2_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_PURIFY_VOLUME, + ATTR_TEMPERATURE, +) +PURIFIER_V3_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_ILLUMINANCE_LUX, + ATTR_MOTOR2_SPEED, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_PURIFY_VOLUME, +) +PURIFIER_PRO_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_ILLUMINANCE_LUX, + ATTR_MOTOR2_SPEED, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_PURIFY_VOLUME, + ATTR_TEMPERATURE, +) +PURIFIER_PRO_V7_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_ILLUMINANCE_LUX, + ATTR_MOTOR2_SPEED, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, +) +AIRFRESH_SENSORS = ( + ATTR_CARBON_DIOXIDE, + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_ILLUMINANCE_LUX, + ATTR_PM25, + ATTR_TEMPERATURE, +) +FAN_V2_V3_SENSORS = ( + ATTR_BATTERY, + 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_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, } -HUMIDIFIER_CA1_CB1_SENSORS = { - ATTR_HUMIDITY: "humidity", - ATTR_TEMPERATURE: "temperature", - ATTR_MOTOR_SPEED: "motor_speed", -} - -HUMIDIFIER_SENSORS_MIOT = { - ATTR_HUMIDITY: "humidity", - ATTR_TEMPERATURE: "temperature", - ATTR_WATER_LEVEL: "water_level", - ATTR_ACTUAL_SPEED: "actual_speed", -} - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Sensor 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, - ) - ) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi sensor from a config entry.""" @@ -178,60 +346,70 @@ async def async_setup_entry(hass, config_entry, async_add_entities): GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, GATEWAY_MODEL_AC_V3, + GATEWAY_MODEL_AQARA, GATEWAY_MODEL_EU, ]: + description = SENSOR_TYPES[ATTR_ILLUMINANCE] entities.append( XiaomiGatewayIlluminanceSensor( - gateway, config_entry.title, config_entry.unique_id + gateway, config_entry.title, config_entry.unique_id, description ) ) # Gateway sub devices sub_devices = gateway.devices coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for sub_device in sub_devices.values(): - sensor_variables = set(sub_device.status) & set(SENSOR_TYPES) - if sensor_variables: - entities.extend( - [ - XiaomiGatewaySensor( - coordinator, sub_device, config_entry, variable - ) - for variable in sensor_variables - ] + for sensor, description in SENSOR_TYPES.items(): + if sensor not in sub_device.status: + continue + entities.append( + XiaomiGatewaySensor( + coordinator, sub_device, config_entry, description + ) ) elif config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] model = config_entry.data[CONF_MODEL] - device = None + device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) sensors = [] - if model in MODELS_HUMIDIFIER_MIOT: - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - sensors = HUMIDIFIER_SENSORS_MIOT - elif model in (MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1): - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - sensors = HUMIDIFIER_CA1_CB1_SENSORS - elif model.startswith("zhimi.humidifier."): - device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - sensors = HUMIDIFIER_SENSORS + if model in (MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, MODEL_FAN_P5): + return + if model in MODEL_TO_SENSORS_MAP: + sensors = MODEL_TO_SENSORS_MAP[model] + elif model in MODELS_HUMIDIFIER_MIOT: + sensors = HUMIDIFIER_MIOT_SENSORS + elif model in MODELS_HUMIDIFIER_MJJSQ: + sensors = HUMIDIFIER_MJJSQ_SENSORS + elif model in MODELS_HUMIDIFIER_MIIO: + sensors = HUMIDIFIER_MIIO_SENSORS + elif model in MODELS_PURIFIER_MIIO: + sensors = PURIFIER_MIIO_SENSORS + elif model in MODELS_PURIFIER_MIOT: + sensors = PURIFIER_MIOT_SENSORS else: unique_id = config_entry.unique_id name = config_entry.title _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) device = AirQualityMonitor(host, token) + description = SENSOR_TYPES[ATTR_AIR_QUALITY] entities.append( - XiaomiAirQualityMonitor(name, device, config_entry, unique_id) + XiaomiAirQualityMonitor( + name, device, config_entry, unique_id, description + ) ) - for sensor in sensors: + for sensor, description in SENSOR_TYPES.items(): + if sensor not in sensors: + continue entities.append( XiaomiGenericSensor( - f"{config_entry.title} {sensor.replace('_', ' ').title()}", + f"{config_entry.title} {description.name}", device, config_entry, f"{sensor}_{config_entry.unique_id}", - sensor, hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, ) ) @@ -241,37 +419,29 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): """Representation of a Xiaomi Humidifier sensor.""" - def __init__(self, name, device, entry, unique_id, attribute, coordinator): + def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the entity.""" super().__init__(name, device, entry, unique_id, coordinator) - self._sensor_config = SENSOR_TYPES[attribute] - self._attr_device_class = self._sensor_config.device_class - self._attr_state_class = self._sensor_config.state_class - self._attr_icon = self._sensor_config.icon self._attr_name = name self._attr_unique_id = unique_id - self._attr_unit_of_measurement = self._sensor_config.unit - self._device = device - self._entry = entry - self._attribute = attribute - self._state = None + self.entity_description = description @property - def state(self): + def native_value(self): """Return the state of the device.""" - self._state = self._extract_value_from_attribute( - self.coordinator.data, self._attribute + return self._extract_value_from_attribute( + self.coordinator.data, self.entity_description.key ) - if ( - self._sensor_config.valid_min_value - and self._state < self._sensor_config.valid_min_value - ) or ( - self._sensor_config.valid_max_value - and self._state > self._sensor_config.valid_max_value - ): - return None - return self._state + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + attr: self._extract_value_from_attribute(self.coordinator.data, attr) + for attr in self.entity_description.attributes + if hasattr(self.coordinator.data, attr) + } @staticmethod def _extract_value_from_attribute(state, attribute): @@ -285,12 +455,10 @@ class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): """Representation of a Xiaomi Air Quality Monitor.""" - def __init__(self, name, device, entry, unique_id): + def __init__(self, name, device, entry, unique_id, description): """Initialize the entity.""" super().__init__(name, device, entry, unique_id) - self._icon = "mdi:cloud" - self._unit_of_measurement = "AQI" self._available = None self._state = None self._state_attrs = { @@ -303,16 +471,7 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): ATTR_NIGHT_TIME_END: None, ATTR_SENSOR_STATE: None, } - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return self._icon + self.entity_description = description @property def available(self): @@ -320,7 +479,7 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): return self._available @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state @@ -359,76 +518,40 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): class XiaomiGatewaySensor(XiaomiGatewayDevice, SensorEntity): """Representation of a XiaomiGatewaySensor.""" - def __init__(self, coordinator, sub_device, entry, data_key): + def __init__(self, coordinator, sub_device, entry, description): """Initialize the XiaomiSensor.""" super().__init__(coordinator, sub_device, entry) - self._data_key = data_key - self._unique_id = f"{sub_device.sid}-{data_key}" - self._name = f"{data_key} ({sub_device.sid})".capitalize() + self._unique_id = f"{sub_device.sid}-{description.key}" + self._name = f"{description.key} ({sub_device.sid})".capitalize() + self.entity_description = description @property - def icon(self): - """Return the icon to use in the frontend.""" - return SENSOR_TYPES[self._data_key].icon - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self._data_key].unit - - @property - def device_class(self): - """Return the device class of this entity.""" - return SENSOR_TYPES[self._data_key].device_class - - @property - def state_class(self): - """Return the state class of this entity.""" - return SENSOR_TYPES[self._data_key].state_class - - @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - return self._sub_device.status[self._data_key] + return self._sub_device.status[self.entity_description.key] class XiaomiGatewayIlluminanceSensor(SensorEntity): """Representation of the gateway device's illuminance sensor.""" - _attr_device_class = DEVICE_CLASS_ILLUMINANCE - _attr_unit_of_measurement = UNIT_LUMEN - - def __init__(self, gateway_device, gateway_name, gateway_device_id): + def __init__(self, gateway_device, gateway_name, gateway_device_id, description): """Initialize the entity.""" + + self._attr_name = f"{gateway_name} {description.name}" + self._attr_unique_id = f"{gateway_device_id}-{description.key}" + self._attr_device_info = {"identifiers": {(DOMAIN, gateway_device_id)}} self._gateway = gateway_device - self._name = f"{gateway_name} Illuminance" - self._gateway_device_id = gateway_device_id - self._unique_id = f"{gateway_device_id}-illuminance" + self.entity_description = description self._available = False self._state = None - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info of the gateway.""" - return {"identifiers": {(DOMAIN, self._gateway_device_id)}} - - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name - @property def available(self): """Return true when state is known.""" return self._available @property - def state(self): + def native_value(self): """Return the state of the device.""" return self._state diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml index 4c153292d7e..b8f81e1a34d 100644 --- a/homeassistant/components/xiaomi_miio/services.yaml +++ b/homeassistant/components/xiaomi_miio/services.yaml @@ -1,186 +1,3 @@ -fan_set_buzzer_on: - name: Fan set buzzer on - description: Turn the buzzer on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_buzzer_off: - name: Fan set buzzer off - description: Turn the buzzer off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_led_on: - name: Fan set LED on - description: Turn the led on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_led_off: - name: Fan set LED off - description: Turn the led off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_child_lock_on: - name: Fan set child lock on - description: Turn the child lock on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_child_lock_off: - name: Fan set child lock off - description: Turn the child lock off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_favorite_level: - name: Fan set favorite level - description: Set the favorite level. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - level: - name: Level - description: Level. - required: true - selector: - number: - min: 0 - max: 17 - -fan_set_fan_level: - name: Fan set level - description: Set the fan level. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - level: - name: Level - description: Level. - selector: - number: - min: 1 - max: 3 - -fan_set_led_brightness: - name: Fan set LED brightness - description: Set the led brightness. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - brightness: - description: Brightness (0 = Bright, 1 = Dim, 2 = Off) - required: true - selector: - number: - min: 0 - max: 2 - -fan_set_auto_detect_on: - name: Fan set auto detect on - description: Turn the auto detect on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_auto_detect_off: - name: Fan set auto detect off - description: Turn the auto detect off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_learn_mode_on: - name: Fan set learn mode on - description: Turn the learn mode on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_learn_mode_off: - name: Fan set learn mode off - description: Turn the learn mode off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_volume: - name: Fan set volume - description: Set the sound volume. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - volume: - description: Volume. - required: true - selector: - number: - min: 0 - max: 100 - fan_reset_filter: name: Fan reset filter description: Reset the filter lifetime and usage. @@ -211,26 +28,6 @@ fan_set_extra_features: min: 0 max: 1 -fan_set_motor_speed: - name: Fan set motor speed - description: Set the target motor speed. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - motor_speed: - name: Motor speed - description: Set motor speed. - required: true - selector: - number: - min: 200 - max: 2000 - unit_of_measurement: 'RPM' - light_set_scene: name: Light set scene description: Set a fixed scene. diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 69a1621c973..129f6f1ecbf 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -12,7 +12,7 @@ "unknown_device": "The device model is not known, not able to setup the device using config flow.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not login to Xioami Miio Cloud, check the credentials." + "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials." }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index bdf3085f236..c40711f5266 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -1,4 +1,6 @@ """Support for Xiaomi Smart WiFi Socket and Smart Power Strip.""" +from __future__ import annotations + import asyncio from dataclasses import dataclass from enum import Enum @@ -11,16 +13,14 @@ import voluptuous as vol from homeassistant.components.switch import ( DEVICE_CLASS_SWITCH, - PLATFORM_SCHEMA, SwitchEntity, + SwitchEntityDescription, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_TEMPERATURE, CONF_HOST, - CONF_NAME, CONF_TOKEN, ) from homeassistant.core import callback @@ -32,23 +32,48 @@ from .const import ( CONF_GATEWAY, CONF_MODEL, DOMAIN, + FEATURE_FLAGS_AIRFRESH, FEATURE_FLAGS_AIRHUMIDIFIER, FEATURE_FLAGS_AIRHUMIDIFIER_CA4, FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ, + FEATURE_FLAGS_AIRPURIFIER_2S, + FEATURE_FLAGS_AIRPURIFIER_MIIO, + FEATURE_FLAGS_AIRPURIFIER_MIOT, + FEATURE_FLAGS_AIRPURIFIER_PRO, + FEATURE_FLAGS_AIRPURIFIER_PRO_V7, + FEATURE_FLAGS_AIRPURIFIER_V1, + FEATURE_FLAGS_AIRPURIFIER_V3, + FEATURE_FLAGS_FAN, + FEATURE_FLAGS_FAN_P5, + FEATURE_SET_AUTO_DETECT, FEATURE_SET_BUZZER, FEATURE_SET_CHILD_LOCK, FEATURE_SET_CLEAN, FEATURE_SET_DRY, + FEATURE_SET_LEARN_MODE, + FEATURE_SET_LED, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRFRESH_VA2, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CA4, MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRPURIFIER_2H, + MODEL_AIRPURIFIER_2S, + MODEL_AIRPURIFIER_PRO, + MODEL_AIRPURIFIER_PRO_V7, + MODEL_AIRPURIFIER_V1, + MODEL_AIRPURIFIER_V3, + MODEL_FAN_P5, + MODEL_FAN_ZA1, + MODEL_FAN_ZA3, + MODEL_FAN_ZA4, + MODELS_FAN, MODELS_HUMIDIFIER, - SERVICE_SET_BUZZER, - SERVICE_SET_CHILD_LOCK, - SERVICE_SET_CLEAN, - SERVICE_SET_DRY, + MODELS_HUMIDIFIER_MJJSQ, + MODELS_PURIFIER_MIIO, + MODELS_PURIFIER_MIOT, SERVICE_SET_POWER_MODE, SERVICE_SET_POWER_PRICE, SERVICE_SET_WIFI_LED_OFF, @@ -73,40 +98,21 @@ GATEWAY_SWITCH_VARS = { "status_ch2": {KEY_CHANNEL: 2}, } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MODEL): vol.In( - [ - "chuangmi.plug.v1", - "qmi.powerstrip.v1", - "zimi.powerstrip.v2", - "chuangmi.plug.m1", - "chuangmi.plug.m3", - "chuangmi.plug.v2", - "chuangmi.plug.v3", - "chuangmi.plug.hmi205", - "chuangmi.plug.hmi206", - "chuangmi.plug.hmi208", - "lumi.acpartner.v3", - ] - ), - } -) -ATTR_POWER = "power" -ATTR_LOAD_POWER = "load_power" -ATTR_MODEL = "model" -ATTR_POWER_MODE = "power_mode" -ATTR_WIFI_LED = "wifi_led" -ATTR_POWER_PRICE = "power_price" -ATTR_PRICE = "price" +ATTR_AUTO_DETECT = "auto_detect" ATTR_BUZZER = "buzzer" ATTR_CHILD_LOCK = "child_lock" -ATTR_DRY = "dry" ATTR_CLEAN = "clean_mode" +ATTR_DRY = "dry" +ATTR_LEARN_MODE = "learn_mode" +ATTR_LED = "led" +ATTR_LOAD_POWER = "load_power" +ATTR_MODEL = "model" +ATTR_POWER = "power" +ATTR_POWER_MODE = "power_mode" +ATTR_POWER_PRICE = "power_price" +ATTR_PRICE = "price" +ATTR_WIFI_LED = "wifi_led" FEATURE_SET_POWER_MODE = 1 FEATURE_SET_WIFI_LED = 2 @@ -143,82 +149,100 @@ SERVICE_TO_METHOD = { "method": "async_set_power_price", "schema": SERVICE_SCHEMA_POWER_PRICE, }, - SERVICE_SET_BUZZER: { - "method_on": "async_set_buzzer_on", - "method_off": "async_set_buzzer_off", - }, - SERVICE_SET_CHILD_LOCK: { - "method_on": "async_set_child_lock_on", - "method_off": "async_set_child_lock_off", - }, - SERVICE_SET_DRY: { - "method_on": "async_set_dry_on", - "method_off": "async_set_dry_off", - }, - SERVICE_SET_CLEAN: { - "method_on": "async_set_clean_on", - "method_off": "async_set_clean_off", - }, +} + +MODEL_TO_FEATURES_MAP = { + MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH, + MODEL_AIRHUMIDIFIER_CA1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + MODEL_AIRHUMIDIFIER_CA4: FEATURE_FLAGS_AIRHUMIDIFIER_CA4, + 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_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_P5: FEATURE_FLAGS_FAN_P5, + MODEL_FAN_ZA1: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA3: FEATURE_FLAGS_FAN, + MODEL_FAN_ZA4: FEATURE_FLAGS_FAN, } @dataclass -class SwitchType: - """Class that holds device specific info for a xiaomi aqara or humidifiers.""" +class XiaomiMiioSwitchDescription(SwitchEntityDescription): + """A class that describes switch entities.""" - name: str = None - short_name: str = None - icon: str = None - service: str = None + feature: int | None = None + method_on: str | None = None + method_off: str | None = None available_with_device_off: bool = True -SWITCH_TYPES = { - FEATURE_SET_BUZZER: SwitchType( +SWITCH_TYPES = ( + XiaomiMiioSwitchDescription( + key=ATTR_BUZZER, + feature=FEATURE_SET_BUZZER, name="Buzzer", icon="mdi:volume-high", - short_name=ATTR_BUZZER, - service=SERVICE_SET_BUZZER, + method_on="async_set_buzzer_on", + method_off="async_set_buzzer_off", ), - FEATURE_SET_CHILD_LOCK: SwitchType( + XiaomiMiioSwitchDescription( + key=ATTR_CHILD_LOCK, + feature=FEATURE_SET_CHILD_LOCK, name="Child Lock", icon="mdi:lock", - short_name=ATTR_CHILD_LOCK, - service=SERVICE_SET_CHILD_LOCK, + method_on="async_set_child_lock_on", + method_off="async_set_child_lock_off", ), - FEATURE_SET_DRY: SwitchType( + XiaomiMiioSwitchDescription( + key=ATTR_DRY, + feature=FEATURE_SET_DRY, name="Dry Mode", icon="mdi:hair-dryer", - short_name=ATTR_DRY, - service=SERVICE_SET_DRY, + method_on="async_set_dry_on", + method_off="async_set_dry_off", ), - FEATURE_SET_CLEAN: SwitchType( + XiaomiMiioSwitchDescription( + key=ATTR_CLEAN, + feature=FEATURE_SET_CLEAN, name="Clean Mode", icon="mdi:sparkles", - short_name=ATTR_CLEAN, - service=SERVICE_SET_CLEAN, + method_on="async_set_clean_on", + method_off="async_set_clean_off", available_with_device_off=False, ), -} - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Switch 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, - ) - ) + XiaomiMiioSwitchDescription( + key=ATTR_LED, + feature=FEATURE_SET_LED, + name="Led", + icon="mdi:led-outline", + method_on="async_set_led_on", + method_off="async_set_led_off", + ), + XiaomiMiioSwitchDescription( + key=ATTR_LEARN_MODE, + feature=FEATURE_SET_LEARN_MODE, + name="Learn Mode", + icon="mdi:school-outline", + method_on="async_set_learn_mode_on", + method_off="async_set_learn_mode_off", + ), + XiaomiMiioSwitchDescription( + key=ATTR_AUTO_DETECT, + feature=FEATURE_SET_AUTO_DETECT, + name="Auto Detect", + method_on="async_set_auto_detect_on", + method_off="async_set_auto_detect_off", + ), +) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the switch from a config entry.""" - if config_entry.data[CONF_MODEL] in MODELS_HUMIDIFIER: + model = config_entry.data[CONF_MODEL] + if model in (*MODELS_HUMIDIFIER, *MODELS_FAN): await async_setup_coordinated_entry(hass, config_entry, async_add_entities) else: await async_setup_other_entry(hass, config_entry, async_add_entities) @@ -237,23 +261,27 @@ async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): device_features = 0 - if model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: - device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB - elif model in [MODEL_AIRHUMIDIFIER_CA4]: - device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA4 + if model in MODEL_TO_FEATURES_MAP: + device_features = MODEL_TO_FEATURES_MAP[model] + elif model in MODELS_HUMIDIFIER_MJJSQ: + device_features = FEATURE_FLAGS_AIRHUMIDIFIER_MJSSQ elif model in MODELS_HUMIDIFIER: device_features = FEATURE_FLAGS_AIRHUMIDIFIER + elif model in MODELS_PURIFIER_MIIO: + device_features = FEATURE_FLAGS_AIRPURIFIER_MIIO + elif model in MODELS_PURIFIER_MIOT: + device_features = FEATURE_FLAGS_AIRPURIFIER_MIOT - for feature, switch in SWITCH_TYPES.items(): - if feature & device_features: + for description in SWITCH_TYPES: + if description.feature & device_features: entities.append( XiaomiGenericCoordinatedSwitch( - f"{config_entry.title} {switch.name}", + f"{config_entry.title} {description.name}", device, config_entry, - f"{switch.short_name}_{unique_id}", - switch, + f"{description.key}_{unique_id}", coordinator, + description, ) ) @@ -382,22 +410,21 @@ async def async_setup_other_entry(hass, config_entry, async_add_entities): class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): """Representation of a Xiaomi Plug Generic.""" - def __init__(self, name, device, entry, unique_id, switch, coordinator): + def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the plug switch.""" super().__init__(name, device, entry, unique_id, coordinator) - self._attr_icon = switch.icon - self._controller = switch self._attr_is_on = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + self.coordinator.data, description.key ) + self.entity_description = description @callback def _handle_coordinator_update(self): """Fetch state from the device.""" # On state change the device doesn't provide the new state immediately. self._attr_is_on = self._extract_value_from_attribute( - self.coordinator.data, self._controller.short_name + self.coordinator.data, self.entity_description.key ) self.async_write_ha_state() @@ -407,7 +434,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): if ( super().available and not self.coordinator.data.is_on - and not self._controller.available_with_device_off + and not self.entity_description.available_with_device_off ): return False return super().available @@ -422,7 +449,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): async def async_turn_on(self, **kwargs) -> None: """Turn on an option of the miio device.""" - method = getattr(self, SERVICE_TO_METHOD[self._controller.service]["method_on"]) + method = getattr(self, self.entity_description.method_on) if await method(): # Write state back to avoid switch flips with a slow response self._attr_is_on = True @@ -430,9 +457,7 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): async def async_turn_off(self, **kwargs) -> None: """Turn off an option of the miio device.""" - method = getattr( - self, SERVICE_TO_METHOD[self._controller.service]["method_off"] - ) + method = getattr(self, self.entity_description.method_off) if await method(): # Write state back to avoid switch flips with a slow response self._attr_is_on = False @@ -502,6 +527,54 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): False, ) + async def async_set_led_on(self) -> bool: + """Turn the led on.""" + return await self._try_command( + "Turning the led of the miio device on failed.", + self._device.set_led, + True, + ) + + async def async_set_led_off(self) -> bool: + """Turn the led off.""" + return await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, + False, + ) + + async def async_set_learn_mode_on(self) -> bool: + """Turn the learn mode on.""" + return await self._try_command( + "Turning the learn mode of the miio device on failed.", + self._device.set_learn_mode, + True, + ) + + async def async_set_learn_mode_off(self) -> bool: + """Turn the learn mode off.""" + return await self._try_command( + "Turning the learn mode of the miio device off failed.", + self._device.set_learn_mode, + False, + ) + + async def async_set_auto_detect_on(self) -> bool: + """Turn auto detect on.""" + return await self._try_command( + "Turning auto detect of the miio device on failed.", + self._device.set_auto_detect, + True, + ) + + async def async_set_auto_detect_off(self) -> bool: + """Turn auto detect off.""" + return await self._try_command( + "Turning auto detect of the miio device off failed.", + self._device.set_auto_detect, + False, + ) + class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): """Representation of a XiaomiGatewaySwitch.""" diff --git a/homeassistant/components/xiaomi_miio/translations/ca.json b/homeassistant/components/xiaomi_miio/translations/ca.json index ff0a24170f6..7e9d7d5c7eb 100644 --- a/homeassistant/components/xiaomi_miio/translations/ca.json +++ b/homeassistant/components/xiaomi_miio/translations/ca.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3", "cloud_credentials_incomplete": "Credencials del n\u00favol incompletes, introdueix el nom d'usuari, la contrasenya i el pa\u00eds", - "cloud_login_error": "No s'ha pogut iniciar sessi\u00f3 a Xioami Miio Cloud, comprova les credencials.", + "cloud_login_error": "No s'ha pogut iniciar sessi\u00f3 a Xiaomi Miio Cloud, comprova les credencials.", "cloud_no_devices": "No s'han trobat dispositius en aquest compte al n\u00favol de Xiaomi Miio.", "no_device_selected": "No hi ha cap dispositiu seleccionat, selecciona'n un.", "unknown_device": "No es reconeix el model del dispositiu, no es pot configurar el dispositiu mitjan\u00e7ant el flux de configuraci\u00f3." diff --git a/homeassistant/components/xiaomi_miio/translations/de.json b/homeassistant/components/xiaomi_miio/translations/de.json index 17363b347c0..24e639e3a23 100644 --- a/homeassistant/components/xiaomi_miio/translations/de.json +++ b/homeassistant/components/xiaomi_miio/translations/de.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "cloud_credentials_incomplete": "Cloud-Anmeldeinformationen unvollst\u00e4ndig, bitte Benutzernamen, Passwort und Land eingeben", - "cloud_login_error": "Konnte sich nicht bei Xioami Miio Cloud anmelden, \u00fcberpr\u00fcfe die Anmeldedaten.", + "cloud_login_error": "Die Anmeldung bei Xiaomi Miio Cloud ist fehlgeschlagen, \u00fcberpr\u00fcfe die Anmeldedaten.", "cloud_no_devices": "Keine Ger\u00e4te in diesem Xiaomi Miio Cloud-Konto gefunden.", "no_device_selected": "Kein Ger\u00e4t ausgew\u00e4hlt, bitte w\u00e4hle ein Ger\u00e4t aus.", "unknown_device": "Das Ger\u00e4temodell ist nicht bekannt und das Ger\u00e4t kann nicht mithilfe des Assistenten eingerichtet werden." diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index cbe10230093..84593a3edc1 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Failed to connect", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not login to Xioami Miio Cloud, check the credentials.", + "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "no_device_selected": "No device selected, please select one device.", "unknown_device": "The device model is not known, not able to setup the device using config flow." diff --git a/homeassistant/components/xiaomi_miio/translations/et.json b/homeassistant/components/xiaomi_miio/translations/et.json index 92d8ffe048f..4eb326d7f08 100644 --- a/homeassistant/components/xiaomi_miio/translations/et.json +++ b/homeassistant/components/xiaomi_miio/translations/et.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "\u00dchendus nurjus", "cloud_credentials_incomplete": "Pilve mandaat on poolik, palun t\u00e4ida kasutajanimi, salas\u00f5na ja riik", - "cloud_login_error": "Xioami Miio Cloudi ei saanud sisse logida, kontrolli mandaati.", + "cloud_login_error": "Xiaomi Miio Cloudi ei saanud sisse logida, kontrolli mandaati.", "cloud_no_devices": "Xiaomi Miio pilvekontolt ei leitud \u00fchtegi seadet.", "no_device_selected": "Seadmeid pole valitud, vali \u00fcks seade.", "unknown_device": "Seadme mudel pole teada, seadet ei saa seadistamisvoo abil seadistada." diff --git a/homeassistant/components/xiaomi_miio/translations/he.json b/homeassistant/components/xiaomi_miio/translations/he.json index e3bf59f9459..69d47597c5f 100644 --- a/homeassistant/components/xiaomi_miio/translations/he.json +++ b/homeassistant/components/xiaomi_miio/translations/he.json @@ -41,7 +41,7 @@ "name": "\u05e9\u05dd \u05d4\u05d4\u05ea\u05e7\u05df", "token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" }, - "description": "\u05d0\u05ea\u05d4 \u05d6\u05e7\u05d5\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API , \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05db\u05d9 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", + "description": "\u05d0\u05ea\u05d4 \u05d6\u05e7\u05d5\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API , \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05db\u05d9 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" }, "gateway": { diff --git a/homeassistant/components/xiaomi_miio/translations/no.json b/homeassistant/components/xiaomi_miio/translations/no.json index 8fa93169647..a296dd7aa08 100644 --- a/homeassistant/components/xiaomi_miio/translations/no.json +++ b/homeassistant/components/xiaomi_miio/translations/no.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes", "cloud_credentials_incomplete": "Utskriftsinformasjon for skyen er fullstendig. Fyll ut brukernavn, passord og land", - "cloud_login_error": "Kunne ikke logge p\u00e5 Xioami Miio Cloud, sjekk legitimasjonen.", + "cloud_login_error": "Kunne ikke logge inn p\u00e5 Xiaomi Miio Cloud, sjekk legitimasjonen.", "cloud_no_devices": "Ingen enheter funnet i denne Xiaomi Miio-skykontoen.", "no_device_selected": "Ingen enhet valgt, vennligst velg en enhet.", "unknown_device": "Enhetsmodellen er ikke kjent, kan ikke konfigurere enheten ved hjelp av konfigurasjonsflyt." diff --git a/homeassistant/components/xiaomi_miio/translations/pl.json b/homeassistant/components/xiaomi_miio/translations/pl.json index dacb0f3f3ec..879d0b8d7ba 100644 --- a/homeassistant/components/xiaomi_miio/translations/pl.json +++ b/homeassistant/components/xiaomi_miio/translations/pl.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "cloud_credentials_incomplete": "Dane logowania do chmury niekompletne, prosz\u0119 poda\u0107 nazw\u0119 u\u017cytkownika, has\u0142o i kraj", - "cloud_login_error": "Nie mo\u017cna zalogowa\u0107 si\u0119 do chmury Xioami Miio, sprawd\u017a po\u015bwiadczenia.", + "cloud_login_error": "Nie mo\u017cna zalogowa\u0107 si\u0119 do chmury Xiaomi Miio, sprawd\u017a po\u015bwiadczenia.", "cloud_no_devices": "Na tym koncie Xiaomi Miio nie znaleziono \u017cadnych urz\u0105dze\u0144.", "no_device_selected": "Nie wybrano \u017cadnego urz\u0105dzenia, wybierz jedno urz\u0105dzenie", "unknown_device": "Model urz\u0105dzenia nie jest znany, nie mo\u017cna skonfigurowa\u0107 urz\u0105dzenia przy u\u017cyciu interfejsu u\u017cytkownika." diff --git a/homeassistant/components/xiaomi_miio/translations/ru.json b/homeassistant/components/xiaomi_miio/translations/ru.json index f9aeb824b20..017660e51c6 100644 --- a/homeassistant/components/xiaomi_miio/translations/ru.json +++ b/homeassistant/components/xiaomi_miio/translations/ru.json @@ -10,7 +10,7 @@ "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "cloud_credentials_incomplete": "\u0423\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0432 \u043e\u0431\u043b\u0430\u043a\u0435 \u043d\u0435\u043f\u043e\u043b\u043d\u044b\u0435. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f, \u043f\u0430\u0440\u043e\u043b\u044c \u0438 \u0441\u0442\u0440\u0430\u043d\u0443.", - "cloud_login_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u043e\u0439\u0442\u0438 \u0432 Xioami Miio Cloud, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "cloud_login_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u043e\u0439\u0442\u0438 \u0432 Xiaomi Miio Cloud, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", "cloud_no_devices": "\u0412 \u044d\u0442\u043e\u0439 \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Xiaomi Miio \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b.", "no_device_selected": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u043e \u0438\u0437 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432.", "unknown_device": "\u041c\u043e\u0434\u0435\u043b\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430, \u043d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u044d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043c\u0430\u0441\u0442\u0435\u0440\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438." diff --git a/homeassistant/components/xiaomi_miio/translations/select.ca.json b/homeassistant/components/xiaomi_miio/translations/select.ca.json new file mode 100644 index 00000000000..bc96de04645 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.ca.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Brillant", + "dim": "Atenua", + "off": "OFF" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.cs.json b/homeassistant/components/xiaomi_miio/translations/select.cs.json new file mode 100644 index 00000000000..d7f5e8b6c84 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.cs.json @@ -0,0 +1,7 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "off": "Vypnuto" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.de.json b/homeassistant/components/xiaomi_miio/translations/select.de.json new file mode 100644 index 00000000000..804eb7a7629 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.de.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Helligkeit", + "dim": "Dimmer", + "off": "Aus" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.en.json b/homeassistant/components/xiaomi_miio/translations/select.en.json new file mode 100644 index 00000000000..60a1d738b81 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.en.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Bright", + "dim": "Dim", + "off": "Off" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.es.json b/homeassistant/components/xiaomi_miio/translations/select.es.json new file mode 100644 index 00000000000..3906ef91342 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.es.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Brillo", + "dim": "Atenuar", + "off": "Apagado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.et.json b/homeassistant/components/xiaomi_miio/translations/select.et.json new file mode 100644 index 00000000000..7195f5703b4 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.et.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Hele", + "dim": "Tuhm", + "off": "Kustu" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.fr.json b/homeassistant/components/xiaomi_miio/translations/select.fr.json new file mode 100644 index 00000000000..29c9afe1e95 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Brillant", + "dim": "Faible", + "off": "\u00c9teint" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.he.json b/homeassistant/components/xiaomi_miio/translations/select.he.json new file mode 100644 index 00000000000..2cffbc3b457 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.he.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "\u05d1\u05d4\u05d9\u05e8", + "dim": "\u05de\u05e2\u05d5\u05de\u05e2\u05dd", + "off": "\u05db\u05d1\u05d5\u05d9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.hu.json b/homeassistant/components/xiaomi_miio/translations/select.hu.json new file mode 100644 index 00000000000..4e6df2b4a33 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.hu.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "F\u00e9nyes", + "dim": "Hom\u00e1lyos", + "off": "Ki" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.it.json b/homeassistant/components/xiaomi_miio/translations/select.it.json new file mode 100644 index 00000000000..21e79e41e99 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.it.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Brillante", + "dim": "Fioca", + "off": "Spento" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.nl.json b/homeassistant/components/xiaomi_miio/translations/select.nl.json new file mode 100644 index 00000000000..eaa69b3170c --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.nl.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Helder", + "dim": "Dim", + "off": "Uit" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.no.json b/homeassistant/components/xiaomi_miio/translations/select.no.json new file mode 100644 index 00000000000..8205447ac2c --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.no.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Lys", + "dim": "Dim", + "off": "Av" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.pl.json b/homeassistant/components/xiaomi_miio/translations/select.pl.json new file mode 100644 index 00000000000..ba5a0ab727f --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "jasne", + "dim": "ciemne", + "off": "wy\u0142\u0105czone" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.ru.json b/homeassistant/components/xiaomi_miio/translations/select.ru.json new file mode 100644 index 00000000000..138d2b4fdce --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "\u042f\u0440\u043a\u043e", + "dim": "\u0422\u0443\u0441\u043a\u043b\u043e", + "off": "\u041e\u0442\u043a\u043b." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/select.zh-Hans.json b/homeassistant/components/xiaomi_miio/translations/select.zh-Hans.json new file mode 100644 index 00000000000..bad6ba91597 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.zh-Hans.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "\u4eae", + "dim": "\u6697", + "off": "\u5173" + } + } +} \ 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 new file mode 100644 index 00000000000..ed977dc9cd5 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/select.zh-Hant.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "\u4eae\u5149", + "dim": "\u8abf\u5149", + "off": "\u95dc\u9589" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/zh-Hans.json b/homeassistant/components/xiaomi_miio/translations/zh-Hans.json index 0034f73fbf3..c3eb4affc4c 100644 --- a/homeassistant/components/xiaomi_miio/translations/zh-Hans.json +++ b/homeassistant/components/xiaomi_miio/translations/zh-Hans.json @@ -56,7 +56,8 @@ "data": { "host": "IP \u5730\u5740", "token": "API Token" - } + }, + "description": "\u60a8\u9700\u8981\u83b7\u53d6\u4e00\u4e2a 32 \u4f4d\u7684 API Token\uff0c\u8bf7\u53c2\u8003 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u4e2d\u63d0\u5230\u7684\u65b9\u6cd5\u83b7\u53d6\u8be5\u4fe1\u606f\u3002\u8bf7\u6ce8\u610f\uff0c\u8be5 API Token \u4e0d\u540c\u4e8e \"Xiaomi Aqara\" \u96c6\u6210\u6240\u4f7f\u7528\u7684\u5bc6\u94a5\u3002" }, "reauth_confirm": { "description": "\u5c0f\u7c73 Miio \u96c6\u6210\u9700\u8981\u91cd\u65b0\u9a8c\u8bc1\u60a8\u7684\u5e10\u6237\uff0c\u4ee5\u4fbf\u66f4\u65b0 token \u6216\u6dfb\u52a0\u4e22\u5931\u7684\u4e91\u7aef\u51ed\u636e\u3002" diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index cdd53e784b3..94d93d77f2f 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant.components.vacuum import ( ATTR_CLEANED_AREA, - PLATFORM_SCHEMA, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, @@ -26,15 +25,13 @@ from homeassistant.components.vacuum import ( SUPPORT_STOP, StateVacuumEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON +from homeassistant.const import CONF_HOST, CONF_TOKEN, STATE_OFF, STATE_ON from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.util.dt import as_utc from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, - DOMAIN, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, @@ -49,15 +46,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Vacuum cleaner" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - }, - extra=vol.ALLOW_EXTRA, -) - ATTR_CLEAN_START = "clean_start" ATTR_CLEAN_STOP = "clean_stop" ATTR_CLEANING_TIME = "cleaning_time" @@ -119,20 +107,6 @@ STATE_CODE_TO_STATE = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Import Miio configuration from YAML.""" - _LOGGER.warning( - "Loading Xiaomi Miio Vacuum 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, - ) - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi vacuum cleaner robot from a config entry.""" entities = [] diff --git a/homeassistant/components/xs1/sensor.py b/homeassistant/components/xs1/sensor.py index f158e7d74b8..ed022f5b9e7 100644 --- a/homeassistant/components/xs1/sensor.py +++ b/homeassistant/components/xs1/sensor.py @@ -37,11 +37,11 @@ class XS1Sensor(XS1DeviceEntity, SensorEntity): return self.device.name() @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self.device.value() @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement.""" return self.device.unit() diff --git a/homeassistant/components/yale_smart_alarm/translations/cs.json b/homeassistant/components/yale_smart_alarm/translations/cs.json index f19158bca25..70947657e4d 100644 --- a/homeassistant/components/yale_smart_alarm/translations/cs.json +++ b/homeassistant/components/yale_smart_alarm/translations/cs.json @@ -9,16 +9,16 @@ "step": { "reauth_confirm": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "ID oblasti", + "name": "Jm\u00e9no", "password": "Heslo", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" } }, "user": { "data": { - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", - "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "ID oblasti", + "name": "Jm\u00e9no", "password": "Heslo", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" } diff --git a/homeassistant/components/yale_smart_alarm/translations/es-419.json b/homeassistant/components/yale_smart_alarm/translations/es-419.json new file mode 100644 index 00000000000..f3cbae5ed03 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/es-419.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID de \u00c1rea" + } + } + } + } +} \ 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 new file mode 100644 index 00000000000..b970badb079 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/es.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n err\u00f3nea" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID de \u00c1rea", + "name": "Nombre", + "password": "Clave", + "username": "Nombre de usuario" + } + }, + "user": { + "data": { + "area_id": "ID de \u00e1rea", + "name": "Nombre", + "password": "Clave", + "username": "Nombre de usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/hu.json b/homeassistant/components/yale_smart_alarm/translations/hu.json new file mode 100644 index 00000000000..8c60574227d --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Ter\u00fclet ID", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + }, + "user": { + "data": { + "area_id": "Ter\u00fclet ID", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json index bbeedb7dc89..eba8861fa46 100644 --- a/homeassistant/components/yale_smart_alarm/translations/no.json +++ b/homeassistant/components/yale_smart_alarm/translations/no.json @@ -1,8 +1,15 @@ { "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "invalid_auth": "Ugyldig godkjenning" + }, "step": { "reauth_confirm": { "data": { + "area_id": "Omr\u00e5de -ID", "name": "Navn", "password": "Passord", "username": "Brukernavn" @@ -10,6 +17,7 @@ }, "user": { "data": { + "area_id": "Omr\u00e5de -ID", "name": "Navn", "password": "Passord", "username": "Brukernavn" diff --git a/homeassistant/components/yale_smart_alarm/translations/ru.json b/homeassistant/components/yale_smart_alarm/translations/ru.json index aedf07d030e..1f2410be1dc 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ru.json +++ b/homeassistant/components/yale_smart_alarm/translations/ru.json @@ -9,7 +9,7 @@ "step": { "reauth_confirm": { "data": { - "area_id": "ID \u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u044f", + "area_id": "ID \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" @@ -17,7 +17,7 @@ }, "user": { "data": { - "area_id": "ID \u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u044f", + "area_id": "ID \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" diff --git a/homeassistant/components/yale_smart_alarm/translations/zh-Hans.json b/homeassistant/components/yale_smart_alarm/translations/zh-Hans.json new file mode 100644 index 00000000000..2afd7efdb15 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/zh-Hans.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u8d26\u53f7\u5df2\u88ab\u914d\u7f6e" + }, + "error": { + "invalid_auth": "\u9a8c\u8bc1\u65e0\u6548" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "\u533a\u57df ID", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + } + }, + "user": { + "data": { + "area_id": "\u533a\u57df ID", + "name": "\u540d\u79f0", + "password": "\u5bc6\u7801", + "username": "\u8d26\u53f7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index 147a983b298..4bf830ed68d 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -1,4 +1,6 @@ """Support for Yamaha Receivers.""" +from __future__ import annotations + import logging import requests @@ -30,6 +32,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( CURSOR_TYPE_DOWN, @@ -96,11 +99,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( class YamahaConfigInfo: """Configuration Info for Yamaha Receivers.""" - def __init__(self, config: None, discovery_info: None) -> None: + def __init__(self, config: ConfigType, discovery_info: DiscoveryInfoType) -> None: """Initialize the Configuration Info for Yamaha Receiver.""" self.name = config.get(CONF_NAME) self.host = config.get(CONF_HOST) - self.ctrl_url = f"http://{self.host}:80/YamahaRemoteControl/ctrl" + self.ctrl_url: str | None = f"http://{self.host}:80/YamahaRemoteControl/ctrl" self.source_ignore = config.get(CONF_SOURCE_IGNORE) self.source_names = config.get(CONF_SOURCE_NAMES) self.zone_ignore = config.get(CONF_ZONE_IGNORE) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 3a8275e98f0..0de8428b0dc 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -7,6 +7,7 @@ import logging from aiomusiccast import MusicCastConnectionException from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -19,7 +20,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import BRAND, DOMAIN +from .const import BRAND, CONF_SERIAL, CONF_UPNP_DESC, DOMAIN PLATFORMS = ["media_player"] @@ -27,10 +28,42 @@ _LOGGER = logging.getLogger(__name__) 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") + matches = [w for w in ssdp_entries if w.get("_host", "") == host] + upnp_desc = None + for match in matches: + if match.get(ssdp.ATTR_SSDP_LOCATION): + upnp_desc = match[ssdp.ATTR_SSDP_LOCATION] + break + + if not upnp_desc: + _LOGGER.warning( + "The upnp_description was not found automatically, setting a default one" + ) + upnp_desc = f"http://{host}:49154/MediaRenderer/desc.xml" + return upnp_desc + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up MusicCast from a config entry.""" - client = MusicCastDevice(entry.data[CONF_HOST], async_get_clientsession(hass)) + if entry.data.get(CONF_UPNP_DESC) is None: + hass.config_entries.async_update_entry( + entry, + data={ + CONF_HOST: entry.data[CONF_HOST], + CONF_SERIAL: entry.data["serial"], + CONF_UPNP_DESC: await get_upnp_desc(hass, entry.data[CONF_HOST]), + }, + ) + + client = MusicCastDevice( + entry.data[CONF_HOST], + async_get_clientsession(hass), + entry.data[CONF_UPNP_DESC], + ) coordinator = MusicCastDataUpdateCoordinator(hass, client=client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 9645be3ddc8..f4ad455fb04 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -15,7 +15,8 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from . import get_upnp_desc +from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -27,6 +28,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): serial_number: str | None = None host: str + upnp_description: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -64,7 +66,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): title=host, data={ CONF_HOST: host, - "serial": serial_number, + CONF_SERIAL: serial_number, + CONF_UPNP_DESC: await get_upnp_desc(self.hass, host), }, ) @@ -89,8 +92,14 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + self.upnp_description = discovery_info[ssdp.ATTR_SSDP_LOCATION] await self.async_set_unique_id(self.serial_number) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + self._abort_if_unique_id_configured( + { + CONF_HOST: self.host, + CONF_UPNP_DESC: self.upnp_description, + } + ) self.context.update( { "title_placeholders": { @@ -108,7 +117,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): title=self.host, data={ CONF_HOST: self.host, - "serial": self.serial_number, + CONF_SERIAL: self.serial_number, + CONF_UPNP_DESC: self.upnp_description, }, ) diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py index b442a3135b9..55ce3920fa1 100644 --- a/homeassistant/components/yamaha_musiccast/const.py +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -1,6 +1,8 @@ """Constants for the MusicCast integration.""" from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_TRACK, REPEAT_MODE_ALL, REPEAT_MODE_OFF, REPEAT_MODE_ONE, @@ -11,16 +13,15 @@ DOMAIN = "yamaha_musiccast" BRAND = "Yamaha Corporation" # Attributes -ATTR_IDENTIFIERS = "identifiers" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" ATTR_PLAYLIST = "playlist" ATTR_PRESET = "preset" -ATTR_SOFTWARE_VERSION = "sw_version" ATTR_MC_LINK = "mc_link" ATTR_MAIN_SYNC = "main_sync" ATTR_MC_LINK_SOURCES = [ATTR_MC_LINK, ATTR_MAIN_SYNC] +CONF_UPNP_DESC = "upnp_description" +CONF_SERIAL = "serial" + DEFAULT_ZONE = "main" HA_REPEAT_MODE_TO_MC_MAPPING = { REPEAT_MODE_OFF: "off", @@ -35,3 +36,9 @@ INTERVAL_SECONDS = "interval_seconds" MC_REPEAT_MODE_TO_HA_MAPPING = { val: key for key, val in HA_REPEAT_MODE_TO_MC_MAPPING.items() } + +MEDIA_CLASS_MAPPING = { + "track": MEDIA_CLASS_TRACK, + "directory": MEDIA_CLASS_DIRECTORY, + "categories": MEDIA_CLASS_DIRECTORY, +} diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 46fae870e5e..bd614e368dc 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -4,13 +4,16 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "requirements": [ - "aiomusiccast==0.8.2" + "aiomusiccast==0.9.1" ], "ssdp": [ { "manufacturer": "Yamaha Corporation" } ], + "dependencies": [ + "ssdp" + ], "iot_class": "local_push", "codeowners": [ "@vigonotion", diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index d08ba798bd8..5081a716357 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -3,17 +3,26 @@ from __future__ import annotations import logging -from aiomusiccast import MusicCastGroupException +from aiomusiccast import MusicCastGroupException, MusicCastMediaContent from aiomusiccast.features import ZoneFeature import voluptuous as vol -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, + BrowseMedia, + MediaPlayerEntity, +) from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_TRACK, + MEDIA_TYPE_MUSIC, REPEAT_MODE_OFF, + SUPPORT_BROWSE_MEDIA, SUPPORT_GROUPING, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_REPEAT_SET, SUPPORT_SELECT_SOUND_MODE, @@ -51,22 +60,19 @@ from .const import ( HA_REPEAT_MODE_TO_MC_MAPPING, INTERVAL_SECONDS, MC_REPEAT_MODE_TO_HA_MAPPING, + MEDIA_CLASS_MAPPING, NULL_GROUP, ) _LOGGER = logging.getLogger(__name__) MUSIC_PLAYER_BASE_SUPPORT = ( - SUPPORT_PAUSE - | SUPPORT_PLAY - | SUPPORT_SHUFFLE_SET + SUPPORT_SHUFFLE_SET | SUPPORT_REPEAT_SET - | SUPPORT_PREVIOUS_TRACK - | SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOURCE - | SUPPORT_STOP | SUPPORT_GROUPING + | SUPPORT_PLAY_MEDIA ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -198,6 +204,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): def _is_tuner(self): return self.coordinator.data.zones[self._zone_id].input == "tuner" + @property + def media_content_id(self): + """Return the content ID of current playing media.""" + return None + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + return MEDIA_TYPE_MUSIC + @property def state(self): """Return the state of the player.""" @@ -308,6 +324,88 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): "Service shuffle is not supported for non NetUSB sources." ) + async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None: + """Play media.""" + if self.state == STATE_OFF: + await self.async_turn_on() + + if media_id: + parts = media_id.split(":") + + if parts[0] == "list": + index = parts[3] + + if index == "-1": + index = "0" + + await self.coordinator.musiccast.play_list_media(index, self._zone_id) + return + + if parts[0] == "presets": + index = parts[1] + await self.coordinator.musiccast.recall_netusb_preset( + self._zone_id, index + ) + return + + if parts[0] == "http": + await self.coordinator.musiccast.play_url_media( + self._zone_id, media_id, "HomeAssistant" + ) + return + + raise HomeAssistantError( + "Only presets, media from media browser and http URLs are supported" + ) + + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + if self.state == STATE_OFF: + raise HomeAssistantError( + "The device has to be turned on to be able to browse media." + ) + + if media_content_id: + media_content_path = media_content_id.split(":") + media_content_provider = await MusicCastMediaContent.browse_media( + self.coordinator.musiccast, self._zone_id, media_content_path, 24 + ) + + else: + media_content_provider = MusicCastMediaContent.categories( + self.coordinator.musiccast, self._zone_id + ) + + def get_content_type(item): + if item.can_play: + return MEDIA_CLASS_TRACK + return MEDIA_CLASS_DIRECTORY + + children = [ + BrowseMedia( + title=child.title, + media_class=MEDIA_CLASS_MAPPING.get(child.content_type), + media_content_id=child.content_id, + media_content_type=get_content_type(child), + can_play=child.can_play, + can_expand=child.can_browse, + thumbnail=child.thumbnail, + ) + for child in media_content_provider.children + ] + + overview = BrowseMedia( + title=media_content_provider.title, + media_class=MEDIA_CLASS_MAPPING.get(media_content_provider.content_type), + media_content_id=media_content_provider.content_id, + media_content_type=get_content_type(media_content_provider), + can_play=False, + can_expand=media_content_provider.can_browse, + children=children, + ) + + return overview + async def async_select_sound_mode(self, sound_mode): """Select sound mode.""" await self.coordinator.musiccast.select_sound_mode(self._zone_id, sound_mode) @@ -366,6 +464,18 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): if ZoneFeature.MUTE in zone.features: supported_features |= SUPPORT_VOLUME_MUTE + if self._is_netusb or self._is_tuner: + supported_features |= SUPPORT_PREVIOUS_TRACK + supported_features |= SUPPORT_NEXT_TRACK + + if self._is_netusb: + supported_features |= SUPPORT_PAUSE + supported_features |= SUPPORT_PLAY + supported_features |= SUPPORT_STOP + + if self.state != STATE_OFF: + supported_features |= SUPPORT_BROWSE_MEDIA + return supported_features async def async_media_previous_track(self): diff --git a/homeassistant/components/yamaha_musiccast/translations/ca.json b/homeassistant/components/yamaha_musiccast/translations/ca.json index 32cd231c963..e1ff37eeb4f 100644 --- a/homeassistant/components/yamaha_musiccast/translations/ca.json +++ b/homeassistant/components/yamaha_musiccast/translations/ca.json @@ -16,7 +16,7 @@ "data": { "host": "Amfitri\u00f3" }, - "description": "Configura MusicCast per a integrar-lo amb Home Assistant." + "description": "Configura la integraci\u00f3 de MusicCast amb Home Assistant." } } } diff --git a/homeassistant/components/yamaha_musiccast/translations/zh-Hans.json b/homeassistant/components/yamaha_musiccast/translations/zh-Hans.json new file mode 100644 index 00000000000..f69ab4546d5 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "description": "\u8bbe\u7f6e MusicCast \u4ee5\u4e0e Home Assistant \u96c6\u6210\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 08e856a721e..b4f7f986626 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -108,7 +108,7 @@ class DiscoverYandexTransport(SensorEntity): self._attrs = attrs @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 2cb754ce6a7..a0deb0fdf21 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -2,25 +2,36 @@ from __future__ import annotations import asyncio +import contextlib from datetime import timedelta +from ipaddress import IPv4Address, IPv6Address import logging +from urllib.parse import urlparse +from async_upnp_client.search import SSDPListener import voluptuous as vol -from yeelight import Bulb, BulbException, discover_bulbs +from yeelight import BulbException +from yeelight.aio import KEY_CONNECTED, AsyncBulb +from homeassistant import config_entries +from homeassistant.components import network from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( CONF_DEVICES, CONF_HOST, CONF_ID, CONF_NAME, - CONF_SCAN_INTERVAL, + EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -46,7 +57,6 @@ CONF_NIGHTLIGHT_SWITCH = "nightlight_switch" DATA_CONFIG_ENTRIES = "config_entries" DATA_CUSTOM_EFFECTS = "custom_effects" -DATA_SCAN_INTERVAL = "scan_interval" DATA_DEVICE = "device" DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher" DATA_PLATFORMS_LOADED = "platforms_loaded" @@ -65,8 +75,13 @@ ACTIVE_COLOR_FLOWING = "1" NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" -SCAN_INTERVAL = timedelta(seconds=30) DISCOVERY_INTERVAL = timedelta(seconds=60) +SSDP_TARGET = ("239.255.255.250", 1982) +SSDP_ST = "wifi_bulb" +DISCOVERY_ATTEMPTS = 3 +DISCOVERY_SEARCH_INTERVAL = timedelta(seconds=2) +DISCOVERY_TIMEOUT = 2 + YEELIGHT_RGB_TRANSITION = "RGBTransition" YEELIGHT_HSV_TRANSACTION = "HSVTransition" @@ -114,7 +129,6 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.Schema( { vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_CUSTOM_EFFECTS): [ { vol.Required(CONF_NAME): cv.string, @@ -149,16 +163,17 @@ UPDATE_REQUEST_PROPERTIES = [ "active_mode", ] +BULB_EXCEPTIONS = (BulbException, asyncio.TimeoutError) + PLATFORMS = ["binary_sensor", "light"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Yeelight bulbs.""" conf = config.get(DOMAIN, {}) hass.data[DOMAIN] = { DATA_CUSTOM_EFFECTS: conf.get(CONF_CUSTOM_EFFECTS, {}), DATA_CONFIG_ENTRIES: {}, - DATA_SCAN_INTERVAL: conf.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), } # Import manually configured devices @@ -183,7 +198,6 @@ async def _async_initialize( entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = { DATA_PLATFORMS_LOADED: False } - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @callback def _async_load_platforms(): @@ -193,71 +207,94 @@ async def _async_initialize( hass.config_entries.async_setup_platforms(entry, PLATFORMS) if not device: + # get device and start listening for local pushes device = await _async_get_device(hass, host, entry) + + await device.async_setup() entry_data[DATA_DEVICE] = device + if ( + device.capabilities + and entry.options.get(CONF_MODEL) != device.capabilities["model"] + ): + hass.config_entries.async_update_entry( + entry, options={**entry.options, CONF_MODEL: device.capabilities["model"]} + ) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( async_dispatcher_connect( hass, DEVICE_INITIALIZED.format(host), _async_load_platforms ) ) - entry.async_on_unload(device.async_unload) - await device.async_setup() + # fetch initial state + asyncio.create_task(device.async_update()) @callback -def _async_populate_entry_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +def _async_normalize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Move options from data for imported entries. Initialize options with default values for other entries. - """ - if entry.options: - return - hass.config_entries.async_update_entry( - entry, - data={CONF_HOST: entry.data.get(CONF_HOST), CONF_ID: entry.data.get(CONF_ID)}, - options={ - CONF_NAME: entry.data.get(CONF_NAME, ""), - CONF_MODEL: entry.data.get(CONF_MODEL, ""), - CONF_TRANSITION: entry.data.get(CONF_TRANSITION, DEFAULT_TRANSITION), - CONF_MODE_MUSIC: entry.data.get(CONF_MODE_MUSIC, DEFAULT_MODE_MUSIC), - CONF_SAVE_ON_CHANGE: entry.data.get( - CONF_SAVE_ON_CHANGE, DEFAULT_SAVE_ON_CHANGE - ), - CONF_NIGHTLIGHT_SWITCH: entry.data.get( - CONF_NIGHTLIGHT_SWITCH, DEFAULT_NIGHTLIGHT_SWITCH - ), - }, - ) + Copy the unique id to CONF_ID if it is missing + """ + if not entry.options: + hass.config_entries.async_update_entry( + entry, + data={ + CONF_HOST: entry.data.get(CONF_HOST), + CONF_ID: entry.data.get(CONF_ID, entry.unique_id), + }, + options={ + CONF_NAME: entry.data.get(CONF_NAME, ""), + CONF_MODEL: entry.data.get(CONF_MODEL, ""), + CONF_TRANSITION: entry.data.get(CONF_TRANSITION, DEFAULT_TRANSITION), + CONF_MODE_MUSIC: entry.data.get(CONF_MODE_MUSIC, DEFAULT_MODE_MUSIC), + CONF_SAVE_ON_CHANGE: entry.data.get( + CONF_SAVE_ON_CHANGE, DEFAULT_SAVE_ON_CHANGE + ), + CONF_NIGHTLIGHT_SWITCH: entry.data.get( + CONF_NIGHTLIGHT_SWITCH, DEFAULT_NIGHTLIGHT_SWITCH + ), + }, + ) + elif entry.unique_id and not entry.data.get(CONF_ID): + hass.config_entries.async_update_entry( + entry, + data={CONF_HOST: entry.data.get(CONF_HOST), CONF_ID: entry.unique_id}, + ) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Yeelight from a config entry.""" - _async_populate_entry_options(hass, entry) + _async_normalize_config_entry(hass, entry) if entry.data.get(CONF_HOST): try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) - except OSError as ex: + 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 else: - # manually added device + # Since device is passed this cannot throw an exception anymore await _async_initialize(hass, entry, entry.data[CONF_HOST], device=device) return True + async def _async_from_discovery(capabilities: dict[str, str]) -> None: + host = urlparse(capabilities["location"]).hostname + try: + await _async_initialize(hass, entry, host) + except BULB_EXCEPTIONS: + _LOGGER.exception("Failed to connect to bulb at %s", host) + # discovery scanner = YeelightScanner.async_get(hass) - - async def _async_from_discovery(host: str) -> None: - await _async_initialize(hass, entry, host) - - scanner.async_register_callback(entry.data[CONF_ID], _async_from_discovery) + await scanner.async_register_callback(entry.data[CONF_ID], _async_from_discovery) return True @@ -275,17 +312,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: scanner = YeelightScanner.async_get(hass) scanner.async_unregister_callback(entry.data[CONF_ID]) + if DATA_DEVICE in entry_data: + device = entry_data[DATA_DEVICE] + _LOGGER.debug("Shutting down Yeelight Listener") + await device.bulb.async_stop_listening() + _LOGGER.debug("Yeelight Listener stopped") + data_config_entries.pop(entry.entry_id) return True +@callback +def async_format_model(model: str) -> str: + """Generate a more human readable model.""" + return model.replace("_", " ").title() + + +@callback +def async_format_id(id_: str) -> str: + """Generate a more human readable id.""" + return hex(int(id_, 16)) if id_ else "None" + + +@callback +def async_format_model_id(model: str, id_: str) -> str: + """Generate a more human readable name.""" + return f"{async_format_model(model)} {async_format_id(id_)}" + + @callback def _async_unique_name(capabilities: dict) -> str: """Generate name from capabilities.""" - model = capabilities["model"] - unique_id = capabilities["id"] - return f"yeelight_{model}_{unique_id}" + model_id = async_format_model_id(capabilities["model"], capabilities["id"]) + return f"Yeelight {model_id}" async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): @@ -309,89 +369,193 @@ class YeelightScanner: def __init__(self, hass: HomeAssistant) -> None: """Initialize class.""" self._hass = hass - self._seen = {} self._callbacks = {} - self._scan_task = None + self._host_discovered_events = {} + self._unique_id_capabilities = {} + self._host_capabilities = {} + self._track_interval = None + self._listeners = [] + self._connected_events = [] - async def _async_scan(self): - _LOGGER.debug("Yeelight scanning") - # Run 3 times as packets can get lost - for _ in range(3): - devices = await self._hass.async_add_executor_job(discover_bulbs) - for device in devices: - unique_id = device["capabilities"]["id"] - if unique_id in self._seen: - continue - host = device["ip"] - self._seen[unique_id] = host - _LOGGER.debug("Yeelight discovered at %s", host) - if unique_id in self._callbacks: - self._hass.async_create_task(self._callbacks[unique_id](host)) - self._callbacks.pop(unique_id) - if len(self._callbacks) == 0: - self._async_stop_scan() + async def async_setup(self): + """Set up the scanner.""" + if self._connected_events: + await asyncio.gather(*(event.wait() for event in self._connected_events)) + return - await asyncio.sleep(SCAN_INTERVAL.total_seconds()) - self._scan_task = self._hass.loop.create_task(self._async_scan()) + for idx, source_ip in enumerate(await self._async_build_source_set()): + self._connected_events.append(asyncio.Event()) + + def _wrap_async_connected_idx(idx): + """Create a function to capture the idx cell variable.""" + + async def _async_connected(): + self._connected_events[idx].set() + + return _async_connected + + self._listeners.append( + SSDPListener( + async_callback=self._async_process_entry, + service_type=SSDP_ST, + target=SSDP_TARGET, + source_ip=source_ip, + async_connect_callback=_wrap_async_connected_idx(idx), + ) + ) + + results = await asyncio.gather( + *(listener.async_start() for listener in self._listeners), + return_exceptions=True, + ) + failed_listeners = [] + for idx, result in enumerate(results): + if not isinstance(result, Exception): + continue + _LOGGER.warning( + "Failed to setup listener for %s: %s", + self._listeners[idx].source_ip, + result, + ) + failed_listeners.append(self._listeners[idx]) + self._connected_events[idx].set() + + for listener in failed_listeners: + self._listeners.remove(listener) + + await asyncio.gather(*(event.wait() for event in self._connected_events)) + self.async_scan() + + async def _async_build_source_set(self) -> set[IPv4Address]: + """Build the list of ssdp sources.""" + adapters = await network.async_get_adapters(self._hass) + sources: set[IPv4Address] = set() + if network.async_only_default_interface_enabled(adapters): + sources.add(IPv4Address("0.0.0.0")) + return sources + + return { + source_ip + for source_ip in await network.async_get_enabled_source_ips(self._hass) + if not source_ip.is_loopback and not isinstance(source_ip, IPv6Address) + } + + async def async_discover(self): + """Discover bulbs.""" + await self.async_setup() + for _ in range(DISCOVERY_ATTEMPTS): + self.async_scan() + await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds()) + return self._unique_id_capabilities.values() @callback - def _async_start_scan(self): + def async_scan(self, *_): + """Send discovery packets.""" + _LOGGER.debug("Yeelight scanning") + for listener in self._listeners: + listener.async_search() + + async def async_get_capabilities(self, host): + """Get capabilities via SSDP.""" + if host in self._host_capabilities: + return self._host_capabilities[host] + + host_event = asyncio.Event() + self._host_discovered_events.setdefault(host, []).append(host_event) + await self.async_setup() + + for listener in self._listeners: + listener.async_search((host, SSDP_TARGET[1])) + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT) + + self._host_discovered_events[host].remove(host_event) + return self._host_capabilities.get(host) + + def _async_discovered_by_ssdp(self, response): + @callback + def _async_start_flow(*_): + asyncio.create_task( + self._hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=response, + ) + ) + + # Delay starting the flow in case the discovery is the result + # of another discovery + async_call_later(self._hass, 1, _async_start_flow) + + async def _async_process_entry(self, response): + """Process a discovery.""" + _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: + _LOGGER.debug("Yeelight discovered with %s", response) + self._async_discovered_by_ssdp(response) + self._host_capabilities[host] = response + self._unique_id_capabilities[unique_id] = response + for event in self._host_discovered_events.get(host, []): + event.set() + if unique_id in self._callbacks: + self._hass.async_create_task(self._callbacks[unique_id](response)) + self._callbacks.pop(unique_id) + if not self._callbacks: + self._async_stop_scan() + + async def _async_start_scan(self): """Start scanning for Yeelight devices.""" _LOGGER.debug("Start scanning") - # Use loop directly to avoid home assistant track this task - self._scan_task = self._hass.loop.create_task(self._async_scan()) + await self.async_setup() + if not self._track_interval: + self._track_interval = async_track_time_interval( + self._hass, self.async_scan, DISCOVERY_INTERVAL + ) + self.async_scan() @callback def _async_stop_scan(self): """Stop scanning.""" - _LOGGER.debug("Stop scanning") - if self._scan_task is not None: - self._scan_task.cancel() - self._scan_task = None + if self._track_interval is None: + return + _LOGGER.debug("Stop scanning interval") + self._track_interval() + self._track_interval = None - @callback - def async_register_callback(self, unique_id, callback_func): + async def async_register_callback(self, unique_id, callback_func): """Register callback function.""" - host = self._seen.get(unique_id) - if host is not None: - self._hass.async_create_task(callback_func(host)) - else: - self._callbacks[unique_id] = callback_func - if len(self._callbacks) == 1: - self._async_start_scan() + if capabilities := self._unique_id_capabilities.get(unique_id): + self._hass.async_create_task(callback_func(capabilities)) + return + self._callbacks[unique_id] = callback_func + await self._async_start_scan() @callback def async_unregister_callback(self, unique_id): """Unregister callback function.""" - if unique_id not in self._callbacks: - return - self._callbacks.pop(unique_id) - if len(self._callbacks) == 0: + self._callbacks.pop(unique_id, None) + if not self._callbacks: self._async_stop_scan() class YeelightDevice: """Represents single Yeelight device.""" - def __init__(self, hass, host, config, bulb, capabilities): + def __init__(self, hass, host, config, bulb): """Initialize device.""" self._hass = hass self._config = config self._host = host self._bulb_device = bulb - self._capabilities = capabilities or {} + self.capabilities = {} self._device_type = None self._available = False - self._remove_time_tracker = None self._initialized = False - - self._name = host # Default name is host - if capabilities: - # Generate name from model and id when capabilities is available - self._name = _async_unique_name(capabilities) - if config.get(CONF_NAME): - # Override default name when name is set in config - self._name = config[CONF_NAME] + self._did_first_update = False + self._name = None @property def bulb(self): @@ -421,12 +585,12 @@ class YeelightDevice: @property def model(self): """Return configured/autodetected device model.""" - return self._bulb_device.model + return self._bulb_device.model or self.capabilities.get("model") @property def fw_version(self): """Return the firmware version.""" - return self._capabilities.get("fw_ver") + return self.capabilities.get("fw_ver") @property def is_nightlight_supported(self) -> bool: @@ -478,35 +642,38 @@ class YeelightDevice: return self._device_type - def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None, power_mode=None): + async def async_turn_on( + self, duration=DEFAULT_TRANSITION, light_type=None, power_mode=None + ): """Turn on device.""" try: - self.bulb.turn_on( + await self.bulb.async_turn_on( duration=duration, light_type=light_type, power_mode=power_mode ) - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) - def turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): + async def async_turn_off(self, duration=DEFAULT_TRANSITION, light_type=None): """Turn off device.""" try: - self.bulb.turn_off(duration=duration, light_type=light_type) - except BulbException as ex: + 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 ) - def _update_properties(self): + async def _async_update_properties(self): """Read new properties from the device.""" if not self.bulb: return try: - self.bulb.get_properties(UPDATE_REQUEST_PROPERTIES) + await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES) self._available = True if not self._initialized: - self._initialize_device() - except BulbException as ex: + self._initialized = True + async_dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) + except BULB_EXCEPTIONS as ex: if self._available: # just inform once _LOGGER.error( "Unable to update device %s, %s: %s", self._host, self.name, ex @@ -515,49 +682,48 @@ class YeelightDevice: return self._available - def _get_capabilities(self): - """Request device capabilities.""" - try: - self.bulb.get_capabilities() - _LOGGER.debug( - "Device %s, %s capabilities: %s", - self._host, - self.name, - self.bulb.capabilities, - ) - except BulbException as ex: - _LOGGER.error( - "Unable to get device capabilities %s, %s: %s", - self._host, - self.name, - ex, - ) - - def _initialize_device(self): - self._get_capabilities() - self._initialized = True - dispatcher_send(self._hass, DEVICE_INITIALIZED.format(self._host)) - - def update(self): - """Update device properties and send data updated signal.""" - self._update_properties() - dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) - async def async_setup(self): - """Set up the device.""" + """Fetch capabilities and setup name if available.""" + scanner = YeelightScanner.async_get(self._hass) + self.capabilities = await scanner.async_get_capabilities(self._host) or {} + if self.capabilities: + self._bulb_device.set_capabilities(self.capabilities) + if name := self._config.get(CONF_NAME): + # Override default name when name is set in config + self._name = name + elif self.capabilities: + # Generate name from model and id when capabilities is available + self._name = _async_unique_name(self.capabilities) + else: + self._name = self._host # Default name is host - async def _async_update(_): - await self._hass.async_add_executor_job(self.update) - - await _async_update(None) - self._remove_time_tracker = async_track_time_interval( - self._hass, _async_update, self._hass.data[DOMAIN][DATA_SCAN_INTERVAL] - ) + async def async_update(self, force=False): + """Update device properties and send data updated signal.""" + self._did_first_update = True + if not force and self._initialized and self._available: + # No need to poll unless force, already connected + return + await self._async_update_properties() + async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) @callback - def async_unload(self): - """Unload the device.""" - self._remove_time_tracker() + 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: + # On reconnect the properties may be out of sync + # + # We need to make sure the DEVICE_INITIALIZED dispatcher is setup + # before we can update on reconnect by checking self._did_first_update + # + # If the device drops the connection right away, we do not want to + # do a property resync via async_update since its about + # to be called when async_setup_entry reaches the end of the + # function + # + asyncio.create_task(self.async_update(True)) + async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host)) class YeelightEntity(Entity): @@ -597,9 +763,9 @@ class YeelightEntity(Entity): """No polling needed.""" return False - def update(self) -> None: + async def async_update(self) -> None: """Update the entity.""" - self._device.update() + await self._device.async_update() async def _async_get_device( @@ -609,7 +775,20 @@ async def _async_get_device( model = entry.options.get(CONF_MODEL) # Set up device - bulb = Bulb(host, model=model or None) - capabilities = await hass.async_add_executor_job(bulb.get_capabilities) + bulb = AsyncBulb(host, model=model or None) - return YeelightDevice(hass, host, entry.options, bulb, capabilities) + device = YeelightDevice(hass, host, entry.options, bulb) + # start listening for local pushes + await device.bulb.async_listen(device.async_update_callback) + + # register stop callback to shutdown listening for local pushes + async def async_stop_listen_task(event): + """Stop listen thread.""" + _LOGGER.debug("Shutting down Yeelight Listener") + await device.bulb.async_stop_listening() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_listen_task) + ) + + return device diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 4fe3709cdd2..185bb504a1b 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -33,6 +33,7 @@ class YeelightNightlightModeSensor(YeelightEntity, BinarySensorEntity): self.async_write_ha_state, ) ) + await super().async_added_to_hass() @property def unique_id(self) -> str: diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index a66571cae93..73bbcdcfe5f 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -1,8 +1,10 @@ """Config flow for Yeelight integration.""" import logging +from urllib.parse import urlparse import voluptuous as vol import yeelight +from yeelight.aio import AsyncBulb from homeassistant import config_entries, exceptions from homeassistant.components.dhcp import IP_ADDRESS @@ -19,7 +21,11 @@ from . import ( CONF_TRANSITION, DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, + YeelightScanner, _async_unique_name, + async_format_id, + async_format_model, + async_format_model_id, ) MODEL_UNKNOWN = "unknown" @@ -54,6 +60,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_ip = discovery_info[IP_ADDRESS] 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 + await self.async_set_unique_id(discovery_info["id"]) + 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_handle_discovery(self): """Handle any discovery.""" self.context[CONF_HOST] = self._discovered_ip @@ -62,7 +77,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_in_progress") try: - self._discovered_model = await self._async_try_connect(self._discovered_ip) + self._discovered_model = await self._async_try_connect( + self._discovered_ip, raise_on_progress=True + ) except CannotConnect: return self.async_abort(reason="cannot_connect") @@ -78,12 +95,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Confirm discovery.""" if user_input is not None: return self.async_create_entry( - title=f"{self._discovered_model} {self.unique_id}", - data={CONF_ID: self.unique_id, CONF_HOST: self._discovered_ip}, + title=async_format_model_id(self._discovered_model, self.unique_id), + data={ + CONF_ID: self.unique_id, + CONF_HOST: self._discovered_ip, + CONF_MODEL: self._discovered_model, + }, ) self._set_confirm_only() - placeholders = {"model": self._discovered_model, "host": self._discovered_ip} + placeholders = { + "id": async_format_id(self.unique_id), + "model": async_format_model(self._discovered_model), + "host": self._discovered_ip, + } self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="discovery_confirm", description_placeholders=placeholders @@ -96,13 +121,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not user_input.get(CONF_HOST): return await self.async_step_pick_device() try: - model = await self._async_try_connect(user_input[CONF_HOST]) + model = await self._async_try_connect( + user_input[CONF_HOST], raise_on_progress=False + ) except CannotConnect: errors["base"] = "cannot_connect" else: self._abort_if_unique_id_configured() return self.async_create_entry( - title=f"{model} {self.unique_id}", data=user_input + title=async_format_model_id(model, self.unique_id), + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_ID: self.unique_id, + CONF_MODEL: model, + }, ) user_input = user_input or {} @@ -119,10 +151,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: unique_id = user_input[CONF_DEVICE] capabilities = self._discovered_devices[unique_id] - await self.async_set_unique_id(unique_id) + await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() + host = urlparse(capabilities["location"]).hostname return self.async_create_entry( - title=_async_unique_name(capabilities), data={CONF_ID: unique_id} + title=_async_unique_name(capabilities), + data={ + CONF_ID: unique_id, + CONF_HOST: host, + CONF_MODEL: capabilities["model"], + }, ) configured_devices = { @@ -131,19 +169,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if entry.data[CONF_ID] } devices_name = {} + scanner = YeelightScanner.async_get(self.hass) + devices = await scanner.async_discover() # Run 3 times as packets can get lost - for _ in range(3): - devices = await self.hass.async_add_executor_job(yeelight.discover_bulbs) - for device in devices: - capabilities = device["capabilities"] - unique_id = capabilities["id"] - if unique_id in configured_devices: - continue # ignore configured devices - model = capabilities["model"] - host = device["ip"] - name = f"{host} {model} {unique_id}" - self._discovered_devices[unique_id] = capabilities - devices_name[unique_id] = name + for capabilities in devices: + unique_id = capabilities["id"] + if unique_id in configured_devices: + continue # ignore configured devices + model = capabilities["model"] + host = urlparse(capabilities["location"]).hostname + model_id = async_format_model_id(model, unique_id) + name = f"{model_id} ({host})" + self._discovered_devices[unique_id] = capabilities + devices_name[unique_id] = name # Check if there is at least one device if not devices_name: @@ -157,7 +195,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle import step.""" host = user_input[CONF_HOST] try: - await self._async_try_connect(host) + await self._async_try_connect(host, raise_on_progress=False) except CannotConnect: _LOGGER.error("Failed to import %s: cannot connect", host) return self.async_abort(reason="cannot_connect") @@ -169,27 +207,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) - async def _async_try_connect(self, host): + async def _async_try_connect(self, host, raise_on_progress=True): """Set up with options.""" self._async_abort_entries_match({CONF_HOST: host}) - bulb = yeelight.Bulb(host) - try: - capabilities = await self.hass.async_add_executor_job(bulb.get_capabilities) - if capabilities is None: # timeout - _LOGGER.debug("Failed to get capabilities from %s: timeout", host) - else: - _LOGGER.debug("Get capabilities: %s", capabilities) - await self.async_set_unique_id(capabilities["id"]) - return capabilities["model"] - except OSError as err: - _LOGGER.debug("Failed to get capabilities from %s: %s", host, err) - # Ignore the error since get_capabilities uses UDP discovery packet - # which does not work in all network environments - + scanner = YeelightScanner.async_get(self.hass) + capabilities = await scanner.async_get_capabilities(host) + if capabilities is None: # timeout + _LOGGER.debug("Failed to get capabilities from %s: timeout", host) + else: + _LOGGER.debug("Get capabilities: %s", capabilities) + await self.async_set_unique_id( + capabilities["id"], raise_on_progress=raise_on_progress + ) + return capabilities["model"] # Fallback to get properties + bulb = AsyncBulb(host) try: - await self.hass.async_add_executor_job(bulb.get_properties) + await bulb.async_listen(lambda _: True) + await bulb.async_get_properties() + await bulb.async_stop_listening() except yeelight.BulbException as err: _LOGGER.error("Failed to get properties from %s: %s", host, err) raise CannotConnect from err diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8d0a3b0ffd4..e0c21f21fc7 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,12 +1,12 @@ """Light platform support for yeelight.""" from __future__ import annotations -from functools import partial import logging +import math import voluptuous as vol import yeelight -from yeelight import Bulb, BulbException, Flow, RGBTransition, SleepTransition, flows +from yeelight import Bulb, Flow, RGBTransition, SleepTransition, flows from yeelight.enums import BulbType, LightType, PowerMode, SceneClass from homeassistant.components.light import ( @@ -49,6 +49,7 @@ from . import ( ATTR_COUNT, ATTR_MODE_MUSIC, ATTR_TRANSITIONS, + BULB_EXCEPTIONS, CONF_FLOW_PARAMS, CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH, @@ -234,17 +235,17 @@ def _parse_custom_effects(effects_config): return effects -def _cmd(func): +def _async_cmd(func): """Define a wrapper to catch exceptions from the bulb.""" - def _wrap(self, *args, **kwargs): + async def _async_wrap(self, *args, **kwargs): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) - return func(self, *args, **kwargs) - except BulbException as ex: + return await func(self, *args, **kwargs) + except BULB_EXCEPTIONS as ex: _LOGGER.error("Error when calling %s: %s", func, ex) - return _wrap + return _async_wrap async def async_setup_entry( @@ -306,36 +307,27 @@ def _async_setup_services(hass: HomeAssistant): params = {**service_call.data} params.pop(ATTR_ENTITY_ID) params[ATTR_TRANSITIONS] = _transitions_config_parser(params[ATTR_TRANSITIONS]) - await hass.async_add_executor_job(partial(entity.start_flow, **params)) + await entity.async_start_flow(**params) async def _async_set_color_scene(entity, service_call): - await hass.async_add_executor_job( - partial( - entity.set_scene, - SceneClass.COLOR, - *service_call.data[ATTR_RGB_COLOR], - service_call.data[ATTR_BRIGHTNESS], - ) + await entity.async_set_scene( + SceneClass.COLOR, + *service_call.data[ATTR_RGB_COLOR], + service_call.data[ATTR_BRIGHTNESS], ) async def _async_set_hsv_scene(entity, service_call): - await hass.async_add_executor_job( - partial( - entity.set_scene, - SceneClass.HSV, - *service_call.data[ATTR_HS_COLOR], - service_call.data[ATTR_BRIGHTNESS], - ) + await entity.async_set_scene( + SceneClass.HSV, + *service_call.data[ATTR_HS_COLOR], + service_call.data[ATTR_BRIGHTNESS], ) async def _async_set_color_temp_scene(entity, service_call): - await hass.async_add_executor_job( - partial( - entity.set_scene, - SceneClass.CT, - service_call.data[ATTR_KELVIN], - service_call.data[ATTR_BRIGHTNESS], - ) + await entity.async_set_scene( + SceneClass.CT, + service_call.data[ATTR_KELVIN], + service_call.data[ATTR_BRIGHTNESS], ) async def _async_set_color_flow_scene(entity, service_call): @@ -344,24 +336,19 @@ def _async_setup_services(hass: HomeAssistant): action=Flow.actions[service_call.data[ATTR_ACTION]], transitions=_transitions_config_parser(service_call.data[ATTR_TRANSITIONS]), ) - await hass.async_add_executor_job( - partial(entity.set_scene, SceneClass.CF, flow) - ) + await entity.async_set_scene(SceneClass.CF, flow) async def _async_set_auto_delay_off_scene(entity, service_call): - await hass.async_add_executor_job( - partial( - entity.set_scene, - SceneClass.AUTO_DELAY_OFF, - service_call.data[ATTR_BRIGHTNESS], - service_call.data[ATTR_MINUTES], - ) + await entity.async_set_scene( + SceneClass.AUTO_DELAY_OFF, + service_call.data[ATTR_BRIGHTNESS], + service_call.data[ATTR_MINUTES], ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( - SERVICE_SET_MODE, SERVICE_SCHEMA_SET_MODE, "set_mode" + SERVICE_SET_MODE, SERVICE_SCHEMA_SET_MODE, "async_set_mode" ) platform.async_register_entity_service( SERVICE_START_FLOW, SERVICE_SCHEMA_START_FLOW, _async_start_flow @@ -405,8 +392,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity): self.config = device.config self._color_temp = None - self._hs = None - self._rgb = None self._effect = None model_specs = self._bulb.get_model_specs() @@ -420,19 +405,16 @@ class YeelightGenericLight(YeelightEntity, LightEntity): else: self._custom_effects = {} - @callback - def _schedule_immediate_update(self): - self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): """Handle entity which will be added.""" self.async_on_remove( async_dispatcher_connect( self.hass, DATA_UPDATED.format(self._device.host), - self._schedule_immediate_update, + self.async_write_ha_state, ) ) + await super().async_added_to_hass() @property def supported_features(self) -> int: @@ -502,16 +484,33 @@ class YeelightGenericLight(YeelightEntity, LightEntity): @property def hs_color(self) -> tuple: """Return the color property.""" - return self._hs + hue = self._get_property("hue") + sat = self._get_property("sat") + if hue is None or sat is None: + return None + + return (int(hue), int(sat)) @property def rgb_color(self) -> tuple: """Return the color property.""" - return self._rgb + rgb = self._get_property("rgb") + + if rgb is None: + return None + + rgb = int(rgb) + blue = rgb & 0xFF + green = (rgb >> 8) & 0xFF + red = (rgb >> 16) & 0xFF + + return (red, green, blue) @property def effect(self): """Return the current effect.""" + if not self.device.is_color_flow_enabled: + return None return self._effect @property @@ -561,33 +560,9 @@ class YeelightGenericLight(YeelightEntity, LightEntity): """Return yeelight device.""" return self._device - def update(self): + async def async_update(self): """Update light properties.""" - self._hs = self._get_hs_from_properties() - self._rgb = self._get_rgb_from_properties() - if not self.device.is_color_flow_enabled: - self._effect = None - - def _get_hs_from_properties(self): - hue = self._get_property("hue") - sat = self._get_property("sat") - if hue is None or sat is None: - return None - - return (int(hue), int(sat)) - - def _get_rgb_from_properties(self): - rgb = self._get_property("rgb") - - if rgb is None: - return None - - rgb = int(rgb) - blue = rgb & 0xFF - green = (rgb >> 8) & 0xFF - red = (rgb >> 16) & 0xFF - - return (red, green, blue) + await self.device.async_update() def set_music_mode(self, music_mode) -> None: """Set the music mode on or off.""" @@ -599,53 +574,81 @@ class YeelightGenericLight(YeelightEntity, LightEntity): else: self._bulb.stop_music() - self.device.update() - - @_cmd - def set_brightness(self, brightness, duration) -> None: + @_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 + _LOGGER.debug("Setting brightness: %s", brightness) - self._bulb.set_brightness( + await self._bulb.async_set_brightness( brightness / 255 * 100, duration=duration, light_type=self.light_type ) - @_cmd - def set_hs(self, hs_color, duration) -> None: + @_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 + _LOGGER.debug("Setting HS: %s", hs_color) - self._bulb.set_hsv( + await self._bulb.async_set_hsv( hs_color[0], hs_color[1], duration=duration, light_type=self.light_type ) - @_cmd - def set_rgb(self, rgb, duration) -> None: + @_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 + _LOGGER.debug("Setting RGB: %s", rgb) - self._bulb.set_rgb( - rgb[0], rgb[1], rgb[2], duration=duration, light_type=self.light_type + await self._bulb.async_set_rgb( + *rgb, duration=duration, light_type=self.light_type ) - @_cmd - def set_colortemp(self, colortemp, duration) -> None: + @_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) - _LOGGER.debug("Setting color temp: %s K", temp_in_k) - self._bulb.set_color_temp( + 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 ) - @_cmd - def set_default(self) -> None: + @_async_cmd + async def async_set_default(self) -> None: """Set current options as default.""" - self._bulb.set_default() + await self._bulb.async_set_default() - @_cmd - def set_flash(self, flash) -> None: + @_async_cmd + async def async_set_flash(self, flash) -> None: """Activate flash.""" if flash: if int(self._bulb.last_properties["color_mode"]) != 1: @@ -660,7 +663,7 @@ class YeelightGenericLight(YeelightEntity, LightEntity): count = 1 duration = transition * 2 - red, green, blue = color_util.color_hs_to_RGB(*self._hs) + red, green, blue = color_util.color_hs_to_RGB(*self.hs_color) transitions = [] transitions.append( @@ -675,18 +678,18 @@ class YeelightGenericLight(YeelightEntity, LightEntity): flow = Flow(count=count, transitions=transitions) try: - self._bulb.start_flow(flow, light_type=self.light_type) - except BulbException as ex: + 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) - @_cmd - def set_effect(self, effect) -> None: + @_async_cmd + async def async_set_effect(self, effect) -> None: """Activate effect.""" if not effect: return if effect == EFFECT_STOP: - self._bulb.stop_flow(light_type=self.light_type) + await self._bulb.async_stop_flow(light_type=self.light_type) return if effect in self.custom_effects_names: @@ -705,12 +708,12 @@ class YeelightGenericLight(YeelightEntity, LightEntity): return try: - self._bulb.start_flow(flow, light_type=self.light_type) + await self._bulb.async_start_flow(flow, light_type=self.light_type) self._effect = effect - except BulbException as ex: + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set effect: %s", ex) - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs) -> None: """Turn the bulb on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) colortemp = kwargs.get(ATTR_COLOR_TEMP) @@ -723,80 +726,88 @@ class YeelightGenericLight(YeelightEntity, LightEntity): if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - self.device.turn_on( - duration=duration, - light_type=self.light_type, - power_mode=self._turn_on_power_mode, - ) + 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, + ) if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode: try: - self.set_music_mode(self.config[CONF_MODE_MUSIC]) - except BulbException as ex: + 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 ) try: # values checked for none in methods - self.set_hs(hs_color, duration) - self.set_rgb(rgb, duration) - self.set_colortemp(colortemp, duration) - self.set_brightness(brightness, duration) - self.set_flash(flash) - self.set_effect(effect) - except BulbException as ex: + 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 # save the current state if we had a manual change. if self.config[CONF_SAVE_ON_CHANGE] and (brightness or colortemp or rgb): try: - self.set_default() - except BulbException as ex: + await self.async_set_default() + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set the defaults: %s", ex) return - self.device.update() - def turn_off(self, **kwargs) -> None: + # 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 def async_turn_off(self, **kwargs) -> None: """Turn off.""" + if not self.is_on: + return + duration = int(self.config[CONF_TRANSITION]) # in ms if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - self.device.turn_off(duration=duration, light_type=self.light_type) - self.device.update() + 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) - def set_mode(self, mode: str): + async def async_set_mode(self, mode: str): """Set a power mode.""" try: - self._bulb.set_power_mode(PowerMode[mode.upper()]) - self.device.update() - except BulbException as ex: + 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) - def start_flow(self, transitions, count=0, action=ACTION_RECOVER): + 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 ) - self._bulb.start_flow(flow, light_type=self.light_type) - self.device.update() - except BulbException as ex: + 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) - def set_scene(self, scene_class, *args): + 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: - self._bulb.set_scene(scene_class, *args) - self.device.update() - except BulbException as ex: + await self._bulb.async_set_scene(scene_class, *args) + except BULB_EXCEPTIONS as ex: _LOGGER.error("Unable to set scene: %s", ex) @@ -849,7 +860,12 @@ class YeelightColorLightWithoutNightlightSwitch( @property def _brightness_property(self): - return "current_brightness" + # 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 YeelightColorLightWithNightlightSwitch( @@ -873,7 +889,12 @@ class YeelightWhiteTempWithoutNightlightSwitch( @property def _brightness_property(self): - return "current_brightness" + # 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( @@ -902,7 +923,7 @@ class YeelightNightLightMode(YeelightGenericLight): @property def name(self) -> str: """Return the name of the device if any.""" - return f"{self.device.name} nightlight" + return f"{self.device.name} Nightlight" @property def icon(self): @@ -994,7 +1015,7 @@ class YeelightAmbientLight(YeelightColorLightWithoutNightlightSwitch): @property def name(self) -> str: """Return the name of the device if any.""" - return f"{self.device.name} ambilight" + return f"{self.device.name} Ambilight" @property def _brightness_property(self): diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 0bf6249b647..0a4b5d4499f 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,14 +2,16 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.6.3"], - "codeowners": ["@rytilahti", "@zewelor", "@shenxn"], + "requirements": ["yeelight==0.7.4", "async-upnp-client==0.20.0"], + "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, - "iot_class": "local_polling", + "dependencies": ["network"], + "quality_scale": "platinum", + "iot_class": "local_push", "dhcp": [{ "hostname": "yeelink-*" }], "homekit": { - "models": ["YLDP*"] + "models": ["YL*"] } } diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index 807fae1ca64..a0ce26550c8 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "user": { "description": "If you leave the host empty, discovery will be used to find devices.", diff --git a/homeassistant/components/yeelight/translations/ca.json b/homeassistant/components/yeelight/translations/ca.json index 9bdbd01bfca..2732806de85 100644 --- a/homeassistant/components/yeelight/translations/ca.json +++ b/homeassistant/components/yeelight/translations/ca.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Ha fallat la connexi\u00f3" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Vols configurar {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/de.json b/homeassistant/components/yeelight/translations/de.json index e0bf573f95e..1547880e5e6 100644 --- a/homeassistant/components/yeelight/translations/de.json +++ b/homeassistant/components/yeelight/translations/de.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "M\u00f6chtest du {model} ({host}) einrichten?" diff --git a/homeassistant/components/yeelight/translations/en.json b/homeassistant/components/yeelight/translations/en.json index 06431e7bc2b..3ed5bbe5515 100644 --- a/homeassistant/components/yeelight/translations/en.json +++ b/homeassistant/components/yeelight/translations/en.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Failed to connect" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Do you want to setup {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/et.json b/homeassistant/components/yeelight/translations/et.json index 450b85b03cd..859bc5f8856 100644 --- a/homeassistant/components/yeelight/translations/et.json +++ b/homeassistant/components/yeelight/translations/et.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u00dchendamine nurjus" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Kas seadistada {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/he.json b/homeassistant/components/yeelight/translations/he.json index adfd4d904ce..535aa833a7f 100644 --- a/homeassistant/components/yeelight/translations/he.json +++ b/homeassistant/components/yeelight/translations/he.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/no.json b/homeassistant/components/yeelight/translations/no.json index bbfe545e919..6814ec518cb 100644 --- a/homeassistant/components/yeelight/translations/no.json +++ b/homeassistant/components/yeelight/translations/no.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Tilkobling mislyktes" }, - "flow_title": "{model} {host}", + "flow_title": "{modell} {id} ({host})", "step": { "discovery_confirm": { "description": "Vil du sette opp {model} ( {host} )?" diff --git a/homeassistant/components/yeelight/translations/pl.json b/homeassistant/components/yeelight/translations/pl.json index 9ea693fffcc..a11706dfd64 100644 --- a/homeassistant/components/yeelight/translations/pl.json +++ b/homeassistant/components/yeelight/translations/pl.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "Czy chcesz skonfigurowa\u0107 {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/ru.json b/homeassistant/components/yeelight/translations/ru.json index cbeaad534b4..34e3c4d2c8a 100644 --- a/homeassistant/components/yeelight/translations/ru.json +++ b/homeassistant/components/yeelight/translations/ru.json @@ -7,7 +7,7 @@ "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} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {model} ({host})?" diff --git a/homeassistant/components/yeelight/translations/zh-Hans.json b/homeassistant/components/yeelight/translations/zh-Hans.json new file mode 100644 index 00000000000..43fb1d9fe25 --- /dev/null +++ b/homeassistant/components/yeelight/translations/zh-Hans.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "no_devices_found": "\u60a8\u7684\u7f51\u7edc\u672a\u53d1\u73b0 Yeelight \u8bbe\u5907" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "flow_title": "{model} {host}", + "step": { + "discovery_confirm": { + "description": "\u60a8\u8981\u8bbe\u7f6e {model} ( {host} )\u5417\uff1f" + }, + "pick_device": { + "data": { + "device": "\u8bbe\u5907" + } + }, + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740" + }, + "description": "\u5982\u679c\u60a8\u5c06\u4e3b\u673a\u5730\u5740\u680f\u7559\u7a7a\uff0c\u5219\u5c06\u81ea\u52a8\u5bfb\u627e\u8bbe\u5907\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "model": "\u578b\u53f7\uff08\u53ef\u9009\uff09", + "nightlight_switch": "\u4f7f\u7528\u591c\u5149\u5f00\u5173", + "save_on_change": "\u4fdd\u5b58\u66f4\u6539\u72b6\u6001", + "transition": "\u8fc7\u6e21\u65f6\u95f4\uff08\u6beb\u79d2\uff09", + "use_music_mode": "\u542f\u7528\u97f3\u4e50\u6a21\u5f0f" + }, + "description": "\u5982\u679c\u5c06\u4fe1\u53f7\u680f\u7559\u7a7a\uff0c\u96c6\u6210\u5c06\u4f1a\u81ea\u52a8\u68c0\u6d4b\u76f8\u5173\u4fe1\u606f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yeelight/translations/zh-Hant.json b/homeassistant/components/yeelight/translations/zh-Hant.json index b5df81beafd..c0c83c213b0 100644 --- a/homeassistant/components/yeelight/translations/zh-Hant.json +++ b/homeassistant/components/yeelight/translations/zh-Hant.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" }, - "flow_title": "{model} {host}", + "flow_title": "{model} {id} ({host})", "step": { "discovery_confirm": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a {model} ({host})\uff1f" diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index c130532a2e1..91dfaab38bf 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -1,12 +1,13 @@ """Support for Xiaomi Cameras (HiSilicon Hi3518e V200).""" -import asyncio +from __future__ import annotations + import logging from aioftp import Client, StatusCodeError from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ( @@ -119,15 +120,18 @@ class YiCamera(Camera): self._is_on = False return None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" url = await self._get_latest_video_url() if url and url != self._last_url: - ffmpeg = ImageFrame(self._manager.binary) - self._last_image = await asyncio.shield( - ffmpeg.get_image( - url, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments - ), + self._last_image = await ffmpeg.async_get_image( + self.hass, + url, + extra_cmd=self._extra_arguments, + width=width, + height=height, ) self._last_url = url diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index 83c8209f558..0980e451028 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, name="youless_gateway", update_method=async_update_data, - update_interval=timedelta(seconds=2), + update_interval=timedelta(seconds=10), ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index d00f0457b85..1ea7bc67ba9 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.10"], + "requirements": ["youless-api==0.12"], "codeowners": ["@gjong"], "iot_class": "local_polling" } diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 54155034919..0b081ab15a2 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -3,11 +3,23 @@ from __future__ import annotations from youless_api.youless_sensor import YoulessSensor +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, +) from homeassistant.components.youless import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, DEVICE_CLASS_POWER +from homeassistant.const import ( + CONF_DEVICE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + VOLUME_CUBIC_METERS, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( @@ -40,7 +52,7 @@ async def async_setup_entry( ) -class YoulessBaseSensor(CoordinatorEntity, Entity): +class YoulessBaseSensor(CoordinatorEntity, SensorEntity): """The base sensor for Youless.""" def __init__( @@ -71,15 +83,7 @@ class YoulessBaseSensor(CoordinatorEntity, Entity): return None @property - def unit_of_measurement(self) -> str | None: - """Return the unit of measurement for the sensor.""" - if self.get_sensor is None: - return None - - return self.get_sensor.unit_of_measurement - - @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Determine the state value, only if a sensor is initialized.""" if self.get_sensor is None: return None @@ -95,6 +99,10 @@ class YoulessBaseSensor(CoordinatorEntity, Entity): class GasSensor(YoulessBaseSensor): """The Youless gas sensor.""" + _attr_native_unit_of_measurement = VOLUME_CUBIC_METERS + _attr_device_class = DEVICE_CLASS_GAS + _attr_state_class = STATE_CLASS_TOTAL_INCREASING + def __init__(self, coordinator: DataUpdateCoordinator, device: str) -> None: """Instantiate a gas sensor.""" super().__init__(coordinator, device, "gas", "Gas meter", "gas") @@ -110,7 +118,9 @@ class GasSensor(YoulessBaseSensor): class CurrentPowerSensor(YoulessBaseSensor): """The current power usage sensor.""" + _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) -> None: """Instantiate the usage meter.""" @@ -127,7 +137,9 @@ class CurrentPowerSensor(YoulessBaseSensor): class DeliveryMeterSensor(YoulessBaseSensor): """The Youless delivery meter value sensor.""" - _attr_device_class = DEVICE_CLASS_POWER + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str @@ -151,7 +163,9 @@ class DeliveryMeterSensor(YoulessBaseSensor): class PowerMeterSensor(YoulessBaseSensor): """The Youless low meter value sensor.""" - _attr_device_class = DEVICE_CLASS_POWER + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str @@ -176,6 +190,7 @@ class PowerMeterSensor(YoulessBaseSensor): 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 def __init__( diff --git a/homeassistant/components/youless/translations/es.json b/homeassistant/components/youless/translations/es.json new file mode 100644 index 00000000000..72a56cc5608 --- /dev/null +++ b/homeassistant/components/youless/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar" + }, + "step": { + "user": { + "data": { + "host": "Anfitri\u00f3n", + "name": "Nombre" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/hu.json b/homeassistant/components/youless/translations/hu.json new file mode 100644 index 00000000000..21c7a7ebe4b --- /dev/null +++ b/homeassistant/components/youless/translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni" + }, + "step": { + "user": { + "data": { + "host": "H\u00e1zigazda", + "name": "N\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/no.json b/homeassistant/components/youless/translations/no.json index 01ea5b65fb1..460c07cb535 100644 --- a/homeassistant/components/youless/translations/no.json +++ b/homeassistant/components/youless/translations/no.json @@ -1,8 +1,12 @@ { "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, "step": { "user": { "data": { + "host": "Vert", "name": "Navn" } } diff --git a/homeassistant/components/youless/translations/zh-Hans.json b/homeassistant/components/youless/translations/zh-Hans.json new file mode 100644 index 00000000000..cfe90f18df3 --- /dev/null +++ b/homeassistant/components/youless/translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a\u5730\u5740", + "name": "\u540d\u79f0" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index a2644287690..ff2e2c4d9ba 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -94,12 +94,12 @@ class ZabbixTriggerCountSensor(SensorEntity): return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the units of measurement.""" return "issues" diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index a6018de831e..054646800a9 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -1,16 +1,20 @@ """Sensor for the Austrian "Zentralanstalt für Meteorologie und Geodynamik".""" +from __future__ import annotations + import csv +from dataclasses import dataclass from datetime import datetime, timedelta import gzip import json import logging import os +from typing import Type, Union from aiohttp.hdrs import USER_AGENT import requests import voluptuous as vol -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( AREA_SQUARE_METERS, ATTR_ATTRIBUTION, @@ -43,72 +47,141 @@ DEFAULT_NAME = "zamg" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") -SENSOR_TYPES = { - "pressure": ("Pressure", PRESSURE_HPA, None, "LDstat hPa", float), - "pressure_sealevel": ( - "Pressure at Sea Level", - PRESSURE_HPA, - None, - "LDred hPa", - float, +DTypeT = Union[Type[int], Type[float], Type[str]] + + +@dataclass +class ZamgRequiredKeysMixin: + """Mixin for required keys.""" + + col_heading: str + dtype: DTypeT + + +@dataclass +class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin): + """Describes Zamg sensor entity.""" + + +SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = ( + ZamgSensorEntityDescription( + key="pressure", + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + col_heading="LDstat hPa", + dtype=float, ), - "humidity": ("Humidity", PERCENTAGE, None, "RF %", int), - "wind_speed": ( - "Wind Speed", - SPEED_KILOMETERS_PER_HOUR, - None, - f"WG {SPEED_KILOMETERS_PER_HOUR}", - float, + ZamgSensorEntityDescription( + key="pressure_sealevel", + name="Pressure at Sea Level", + native_unit_of_measurement=PRESSURE_HPA, + col_heading="LDred hPa", + dtype=float, ), - "wind_bearing": ("Wind Bearing", DEGREE, None, f"WR {DEGREE}", int), - "wind_max_speed": ( - "Top Wind Speed", - None, - SPEED_KILOMETERS_PER_HOUR, - f"WSG {SPEED_KILOMETERS_PER_HOUR}", - float, + ZamgSensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + col_heading="RF %", + dtype=int, ), - "wind_max_bearing": ("Top Wind Bearing", DEGREE, None, f"WSR {DEGREE}", int), - "sun_last_hour": ("Sun Last Hour", PERCENTAGE, None, f"SO {PERCENTAGE}", int), - "temperature": ( - "Temperature", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - f"T {TEMP_CELSIUS}", - float, + ZamgSensorEntityDescription( + key="wind_speed", + name="Wind Speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + col_heading=f"WG {SPEED_KILOMETERS_PER_HOUR}", + dtype=float, ), - "precipitation": ( - "Precipitation", - None, - f"l/{AREA_SQUARE_METERS}", - f"N l/{AREA_SQUARE_METERS}", - float, + ZamgSensorEntityDescription( + key="wind_bearing", + name="Wind Bearing", + native_unit_of_measurement=DEGREE, + col_heading=f"WR {DEGREE}", + dtype=int, ), - "dewpoint": ( - "Dew Point", - TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, - f"TP {TEMP_CELSIUS}", - float, + ZamgSensorEntityDescription( + key="wind_max_speed", + name="Top Wind Speed", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + col_heading=f"WSG {SPEED_KILOMETERS_PER_HOUR}", + dtype=float, + ), + ZamgSensorEntityDescription( + key="wind_max_bearing", + name="Top Wind Bearing", + native_unit_of_measurement=DEGREE, + col_heading=f"WSR {DEGREE}", + dtype=int, + ), + ZamgSensorEntityDescription( + key="sun_last_hour", + name="Sun Last Hour", + native_unit_of_measurement=PERCENTAGE, + col_heading=f"SO {PERCENTAGE}", + dtype=int, + ), + ZamgSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + col_heading=f"T {TEMP_CELSIUS}", + dtype=float, + ), + ZamgSensorEntityDescription( + key="precipitation", + name="Precipitation", + native_unit_of_measurement=f"l/{AREA_SQUARE_METERS}", + col_heading=f"N l/{AREA_SQUARE_METERS}", + dtype=float, + ), + ZamgSensorEntityDescription( + key="dewpoint", + name="Dew Point", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + col_heading=f"TP {TEMP_CELSIUS}", + dtype=float, ), # The following probably not useful for general consumption, # but we need them to fill in internal attributes - "station_name": ("Station Name", None, None, "Name", str), - "station_elevation": ( - "Station Elevation", - LENGTH_METERS, - None, - f"Höhe {LENGTH_METERS}", - int, + ZamgSensorEntityDescription( + key="station_name", + name="Station Name", + col_heading="Name", + dtype=str, ), - "update_date": ("Update Date", None, None, "Datum", str), - "update_time": ("Update Time", None, None, "Zeit", str), + ZamgSensorEntityDescription( + key="station_elevation", + name="Station Elevation", + native_unit_of_measurement=LENGTH_METERS, + col_heading=f"Höhe {LENGTH_METERS}", + dtype=int, + ), + ZamgSensorEntityDescription( + key="update_date", + name="Update Date", + col_heading="Datum", + dtype=str, + ), + ZamgSensorEntityDescription( + key="update_time", + name="Update Time", + col_heading="Zeit", + dtype=str, + ), +) + +SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] + +API_FIELDS: dict[str, tuple[str, DTypeT]] = { + desc.col_heading: (desc.key, desc.dtype) for desc in SENSOR_TYPES } PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( { vol.Required(CONF_MONITORED_CONDITIONS, default=["temperature"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(SENSOR_KEYS)] ), vol.Optional(CONF_STATION_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -124,7 +197,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ZAMG sensor platform.""" - name = config.get(CONF_NAME) + name = config[CONF_NAME] latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -146,10 +219,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Received error from ZAMG: %s", err) return False + monitored_conditions = config[CONF_MONITORED_CONDITIONS] add_entities( [ - ZamgSensor(probe, variable, name) - for variable in config[CONF_MONITORED_CONDITIONS] + ZamgSensor(probe, name, description) + for description in SENSOR_TYPES + if description.key in monitored_conditions ], True, ) @@ -158,27 +233,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ZamgSensor(SensorEntity): """Implementation of a ZAMG sensor.""" - def __init__(self, probe, variable, name): + entity_description: ZamgSensorEntityDescription + + def __init__(self, probe, name, description: ZamgSensorEntityDescription): """Initialize the sensor.""" + self.entity_description = description self.probe = probe - self.client_name = name - self.variable = variable - self._attr_device_class = SENSOR_TYPES[variable][2] + self._attr_name = f"{name} {description.key}" @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self.variable}" - - @property - def state(self): + def native_value(self): """Return the state of the sensor.""" - return self.probe.get_data(self.variable) - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self.variable][1] + return self.probe.get_data(self.entity_description.key) @property def extra_state_attributes(self): @@ -238,22 +304,12 @@ class ZamgData: for row in self.current_observations(): if row.get("Station") == self._station_id: - api_fields = { - col_heading: (standard_name, dtype) - for standard_name, ( - _, - _, - _, - col_heading, - dtype, - ) in SENSOR_TYPES.items() - } self.data = { - api_fields.get(col_heading)[0]: api_fields.get(col_heading)[1]( + API_FIELDS[col_heading][0]: API_FIELDS[col_heading][1]( v.replace(",", ".") ) for col_heading, v in row.items() - if col_heading in api_fields and v + if col_heading in API_FIELDS and v } break else: diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index e7132f56b55..17cfb9d05de 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Coroutine from contextlib import suppress import fnmatch -import ipaddress +from ipaddress import IPv6Address, ip_address import logging import socket from typing import Any, TypedDict, cast @@ -28,6 +28,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf @@ -130,14 +131,7 @@ async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZero return aio_zc -def _async_use_default_interface(adapters: list[Adapter]) -> bool: - for adapter in adapters: - if adapter["enabled"] and not adapter["default"]: - return False - return True - - -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Zeroconf and make Home Assistant discoverable.""" zc_args: dict = {} @@ -150,21 +144,15 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: else: zc_args["ip_version"] = IPVersion.All - if not ipv6 and _async_use_default_interface(adapters): + if not ipv6 and network.async_only_default_interface_enabled(adapters): zc_args["interfaces"] = InterfaceChoice.Default else: - interfaces = zc_args["interfaces"] = [] - for adapter in adapters: - if not adapter["enabled"]: - continue - if ipv4s := adapter["ipv4"]: - interfaces.extend( - ipv4["address"] - for ipv4 in ipv4s - if not ipaddress.ip_address(ipv4["address"]).is_loopback - ) - if adapter["ipv6"] and adapter["index"] not in interfaces: - interfaces.append(adapter["index"]) + zc_args["interfaces"] = [ + str(source_ip) + 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) + ] aio_zc = await _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) @@ -208,7 +196,7 @@ def _get_announced_addresses( addresses = { addr.packed for addr in [ - ipaddress.ip_address(ip["address"]) + ip_address(ip["address"]) for adapter in adapters if adapter["enabled"] for ip in cast(list, adapter["ipv6"]) + cast(list, adapter["ipv4"]) @@ -227,7 +215,9 @@ async def _async_register_hass_zc_service( hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str ) -> None: # Get instance UUID - valid_location_name = _truncate_location_name_to_valid(hass.config.location_name) + valid_location_name = _truncate_location_name_to_valid( + hass.config.location_name or "Home" + ) params = { "location_name": valid_location_name, @@ -525,7 +515,7 @@ def info_from_service(service: AsyncServiceInfo) -> HaServiceInfo | None: address = service.addresses[0] return { - "host": str(ipaddress.ip_address(address)), + "host": str(ip_address(address)), "port": service.port, "hostname": service.server, "type": service.type, diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b971ec06179..6ed4c8d09dd 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.35.0"], + "requirements": ["zeroconf==0.36.2"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index ad14d9c506a..3f0136f9b0d 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -16,7 +16,6 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util @@ -30,7 +29,7 @@ SUPPORT_ZERPROC = SUPPORT_BRIGHTNESS | SUPPORT_COLOR DISCOVERY_INTERVAL = timedelta(seconds=60) -async def discover_entities(hass: HomeAssistant) -> list[Entity]: +async def discover_entities(hass: HomeAssistant) -> list[ZerprocLight]: """Attempt to discover new lights.""" lights = await pyzerproc.discover() diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 0333bb76a20..bac32563776 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -77,7 +77,7 @@ class ZestimateDataSensor(SensorEntity): return f"{self._name} {self.address}" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" try: return round(float(self._state), 1) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 00aaf7c3625..4bf255e95a0 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,7 +1,6 @@ """Config flow for ZHA.""" from __future__ import annotations -import os from typing import Any import serial.tools.list_ports @@ -9,6 +8,7 @@ import voluptuous as vol from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries +from homeassistant.components import usb from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.typing import DiscoveryInfoType @@ -25,6 +25,7 @@ SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, CONF_FLOWCONTROL, ) +DECONZ_DOMAIN = "deconz" class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -36,6 +37,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize flow instance.""" self._device_path = None self._radio_type = None + self._title = None async def async_step_user(self, user_input=None): """Handle a zha config flow start.""" @@ -61,7 +63,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): port = ports[list_of_ports.index(user_selection)] dev_path = await self.hass.async_add_executor_job( - get_serial_by_id, port.device + usb.get_serial_by_id, port.device ) auto_detected_data = await detect_radios(dev_path) if auto_detected_data is not None: @@ -92,6 +94,72 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema(schema), ) + async def async_step_usb(self, discovery_info: DiscoveryInfoType): + """Handle usb discovery.""" + vid = discovery_info["vid"] + pid = discovery_info["pid"] + serial_number = discovery_info["serial_number"] + device = discovery_info["device"] + manufacturer = discovery_info["manufacturer"] + description = discovery_info["description"] + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" + if current_entry := await self.async_set_unique_id(unique_id): + self._abort_if_unique_id_configured( + updates={ + CONF_DEVICE: { + **current_entry.data[CONF_DEVICE], + CONF_DEVICE_PATH: dev_path, + }, + } + ) + # Check if already configured + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + # If they already have a discovery for deconz + # we ignore the usb discovery as they probably + # want to use it there instead + for flow in self.hass.config_entries.flow.async_progress(): + if flow["handler"] == DECONZ_DOMAIN: + return self.async_abort(reason="not_zha_device") + for entry in self.hass.config_entries.async_entries(DECONZ_DOMAIN): + if entry.source != config_entries.SOURCE_IGNORE: + return self.async_abort(reason="not_zha_device") + + self._device_path = dev_path + self._title = usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + self._set_confirm_only() + self.context["title_placeholders"] = {CONF_NAME: self._title} + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Confirm a discovery.""" + 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 + return self.async_abort(reason="usb_probe_failed") + return self.async_create_entry( + title=self._title, + data=auto_detected_data, + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={CONF_NAME: self._title}, + data_schema=vol.Schema({}), + ) + async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): """Handle zeroconf discovery.""" # Hostname is format: livingroom.local. @@ -100,12 +168,15 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): host = discovery_info[CONF_HOST] device_path = f"socket://{host}:6638" - await self.async_set_unique_id(node_name) - self._abort_if_unique_id_configured( - updates={ - CONF_DEVICE: {CONF_DEVICE_PATH: device_path}, - } - ) + if current_entry := await self.async_set_unique_id(node_name): + self._abort_if_unique_id_configured( + updates={ + CONF_DEVICE: { + **current_entry.data[CONF_DEVICE], + CONF_DEVICE_PATH: device_path, + }, + } + ) # Check if already configured if self._async_current_entries(): @@ -131,7 +202,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._device_path = user_input.get(CONF_DEVICE_PATH) if await app_cls.probe(user_input): serial_by_id = await self.hass.async_add_executor_job( - get_serial_by_id, user_input[CONF_DEVICE_PATH] + usb.get_serial_by_id, user_input[CONF_DEVICE_PATH] ) user_input[CONF_DEVICE_PATH] = serial_by_id return self.async_create_entry( @@ -167,19 +238,10 @@ async def detect_radios(dev_path: str) -> dict[str, Any] | None: """Probe all radio types on the device port.""" for radio in RadioType: dev_config = radio.controller.SCHEMA_DEVICE({CONF_DEVICE_PATH: dev_path}) - if await radio.controller.probe(dev_config): + probe_result = await radio.controller.probe(dev_config) + if probe_result: + if isinstance(probe_result, dict): + return {CONF_RADIO_TYPE: radio.name, CONF_DEVICE: probe_result} return {CONF_RADIO_TYPE: radio.name, CONF_DEVICE: dev_config} return None - - -def get_serial_by_id(dev_path: str) -> str: - """Return a /dev/serial/by-id match for given device if available.""" - by_id = "/dev/serial/by-id" - if not os.path.isdir(by_id): - return dev_path - - for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): - if os.path.realpath(path) == dev_path: - return path - return dev_path diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index de39ff50511..36696517eb6 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -13,6 +13,8 @@ from .api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN from .core.const import CHANNEL_IAS_WD from .core.helpers import async_get_zha_device +# mypy: disallow-any-generics + ACTION_SQUAWK = "squawk" ACTION_WARN = "warn" ATTR_DATA = "data" @@ -54,7 +56,9 @@ async def async_call_action_from_config( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions.""" try: zha_device = await async_get_zha_device(hass, device_id) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index a5259deea5d..50dd7e16a28 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -10,6 +10,7 @@ from typing import Any from homeassistant.const import ATTR_NAME from homeassistant.core import CALLBACK_TYPE, Event, callback from homeassistant.helpers import entity +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -34,7 +35,7 @@ from .core.typing import CALLABLE_T, ChannelType, ZhaDeviceType _LOGGER = logging.getLogger(__name__) ENTITY_SUFFIX = "entity_suffix" -UPDATE_GROUP_FROM_CHILD_DELAY = 0.2 +UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 class BaseZhaEntity(LogMixin, entity.Entity): @@ -230,6 +231,7 @@ class ZhaGroupEntity(BaseZhaEntity): self._entity_ids: list[str] = entity_ids self._async_unsub_state_changed: CALLBACK_TYPE | None = None self._handled_group_membership = False + self._change_listener_debouncer: Debouncer | None = None @property def available(self) -> bool: @@ -256,6 +258,14 @@ class ZhaGroupEntity(BaseZhaEntity): signal_override=True, ) + if self._change_listener_debouncer is None: + self._change_listener_debouncer = Debouncer( + self.hass, + self, + cooldown=UPDATE_GROUP_FROM_CHILD_DELAY, + immediate=False, + function=functools.partial(self.async_update_ha_state, True), + ) self._async_unsub_state_changed = async_track_state_change_event( self.hass, self._entity_ids, self.async_state_changed_listener ) @@ -271,10 +281,7 @@ class ZhaGroupEntity(BaseZhaEntity): def async_state_changed_listener(self, event: Event): """Handle child updates.""" # Delay to ensure that we get updates from all members before updating the group - self.hass.loop.call_later( - UPDATE_GROUP_FROM_CHILD_DELAY, - lambda: self.async_schedule_update_ha_state(True), - ) + self.hass.create_task(self._change_listener_debouncer.async_call()) async def async_will_remove_from_hass(self) -> None: """Handle removal from Home Assistant.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 628d9c3b9be..a340ffae736 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -523,7 +523,7 @@ class Light(BaseLight, ZhaEntity): @STRICT_MATCH( channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, - manufacturers="Philips", + manufacturers={"Philips", "Signify Netherlands B.V."}, ) class HueLight(Light): """Representation of a HUE light which does not report attributes.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 5200c0a8b31..4b2b27e829c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,16 +4,21 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.26.0", + "bellows==0.27.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.59", + "zha-quirks==0.0.60", "zigpy-cc==0.5.2", - "zigpy-deconz==0.12.1", - "zigpy==0.36.1", - "zigpy-xbee==0.13.0", + "zigpy-deconz==0.13.0", + "zigpy==0.37.1", + "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.5.3" + "zigpy-znp==0.5.4" + ], + "usb": [ + {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, + {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, + {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]} ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ @@ -22,6 +27,6 @@ "name": "tube*" } ], - "after_dependencies": ["zeroconf"], + "after_dependencies": ["usb", "zeroconf"], "iot_class": "local_polling" } diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 3c3aba919ed..cc401cb1e05 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -135,12 +135,12 @@ class Sensor(ZhaEntity, SensorEntity): return self._state_class @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" return self._unit @property - def state(self) -> StateType: + def native_value(self) -> StateType: """Return the state of the entity.""" assert self.SENSOR_ATTR is not None raw_state = self._channel.cluster.get(self.SENSOR_ATTR) @@ -274,7 +274,7 @@ class SmartEnergyMetering(Sensor): return self._channel.formatter_function(value) @property - def unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str: """Return Unit of measurement.""" return self._channel.unit_of_measurement diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 9abff4e83e2..5953df52e92 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -7,6 +7,9 @@ "data": { "path": "Serial Device Path" }, "description": "Select serial port for Zigbee radio" }, + "confirm": { + "description": "Do you want to setup {name}?" + }, "pick_radio": { "data": { "radio_type": "Radio Type" }, "title": "Radio Type", @@ -26,7 +29,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "not_zha_device": "This device is not a zha device", + "usb_probe_failed": "Failed to probe the usb device" } }, "config_panel": { diff --git a/homeassistant/components/zha/translations/ca.json b/homeassistant/components/zha/translations/ca.json index 2467db76709..0e4abfe3a57 100644 --- a/homeassistant/components/zha/translations/ca.json +++ b/homeassistant/components/zha/translations/ca.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Aquest no \u00e9s un dispositiu zha", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Vols configurar {name}?" + }, "pick_radio": { "data": { "radio_type": "Tipus de r\u00e0dio" diff --git a/homeassistant/components/zha/translations/de.json b/homeassistant/components/zha/translations/de.json index 3d66cc63071..2c7c6fed132 100644 --- a/homeassistant/components/zha/translations/de.json +++ b/homeassistant/components/zha/translations/de.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Dieses Ger\u00e4t ist kein ZHA-Ger\u00e4t", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "M\u00f6chtest du {name} einrichten?" + }, "pick_radio": { "data": { "radio_type": "Funktyp" diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index e13aca2cfb1..93d3c5f697a 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "This device is not a zha device", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Do you want to setup {name}?" + }, "pick_radio": { "data": { "radio_type": "Radio Type" diff --git a/homeassistant/components/zha/translations/et.json b/homeassistant/components/zha/translations/et.json index 311a6378fcb..4924b3c954f 100644 --- a/homeassistant/components/zha/translations/et.json +++ b/homeassistant/components/zha/translations/et.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "See seade ei ole zha seade", "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Kas soovid seadistada teenust {name} ?" + }, "pick_radio": { "data": { "radio_type": "Seadme raadio t\u00fc\u00fcp" diff --git a/homeassistant/components/zha/translations/hu.json b/homeassistant/components/zha/translations/hu.json index 2b078092ed7..9722095b548 100644 --- a/homeassistant/components/zha/translations/hu.json +++ b/homeassistant/components/zha/translations/hu.json @@ -8,13 +8,27 @@ }, "flow_title": "{name}", "step": { + "pick_radio": { + "data": { + "radio_type": "R\u00e1di\u00f3 t\u00edpusa" + }, + "description": "V\u00e1lassza ki a Zigbee r\u00e1di\u00f3 t\u00edpus\u00e1t", + "title": "R\u00e1di\u00f3 t\u00edpusa" + }, "port_config": { "data": { - "baudrate": "port sebess\u00e9g" + "baudrate": "port sebess\u00e9g", + "flow_control": "adat\u00e1raml\u00e1s szab\u00e1lyoz\u00e1sa", + "path": "Soros eszk\u00f6z el\u00e9r\u00e9si \u00fatja" }, + "description": "Adja meg a port specifikus be\u00e1ll\u00edt\u00e1sokat", "title": "Be\u00e1ll\u00edt\u00e1sok" }, "user": { + "data": { + "path": "Soros eszk\u00f6z el\u00e9r\u00e9si \u00fatja" + }, + "description": "V\u00e1lassza ki a Zigbee r\u00e1di\u00f3 soros portj\u00e1t", "title": "ZHA" } } @@ -35,11 +49,59 @@ } }, "device_automation": { + "action_type": { + "squawk": "Riaszt\u00e1s", + "warn": "Figyelmeztet\u00e9s" + }, "trigger_subtype": { - "turn_off": "Kikapcsol\u00e1s" + "both_buttons": "Mindk\u00e9t gomb", + "button_1": "Els\u0151 gomb", + "button_2": "M\u00e1sodik gomb", + "button_3": "Harmadik gomb", + "button_4": "Negyedik gomb", + "button_5": "\u00d6t\u00f6dik gomb", + "button_6": "Hatodik gomb", + "close": "Bez\u00e1r\u00e1s", + "dim_down": "S\u00f6t\u00e9t\u00edt", + "dim_up": "Vil\u00e1gos\u00edt", + "face_1": "aktiv\u00e1lt 1 arccal", + "face_2": "aktiv\u00e1lt 2 arccal", + "face_3": "aktiv\u00e1lt 3 arccal", + "face_4": "aktiv\u00e1lt 4 arccal", + "face_5": "aktiv\u00e1lt 5 arccal", + "face_6": "aktiv\u00e1lt 6 arccal", + "face_any": "B\u00e1rmely/meghat\u00e1rozott arc(ok) aktiv\u00e1l\u00e1s\u00e1val", + "left": "Bal", + "open": "Nyitva", + "right": "Jobb", + "turn_off": "Kikapcsol\u00e1s", + "turn_on": "Bekapcsol\u00e1s" }, "trigger_type": { - "device_offline": "Eszk\u00f6z offline" + "device_dropped": "A k\u00e9sz\u00fcl\u00e9k eldobva", + "device_flipped": "Eszk\u00f6z \u00e1tford\u00edtva \"{subtype}\"", + "device_knocked": "Az eszk\u00f6zt le\u00fct\u00f6tt\u00e9k \"{subtype}\"", + "device_offline": "Eszk\u00f6z offline", + "device_rotated": "Eszk\u00f6z elforgatva \"{subtype}\"", + "device_shaken": "A k\u00e9sz\u00fcl\u00e9k megr\u00e1zk\u00f3dott", + "device_slid": "Eszk\u00f6z cs\u00fasztatott \"{subtype}\"", + "device_tilted": "K\u00e9sz\u00fcl\u00e9k megd\u00f6ntve", + "remote_button_alt_double_press": "A \u201e{subtype}\u201d gombra dupl\u00e1n kattintva (Alternat\u00edv m\u00f3d)", + "remote_button_alt_long_press": "\"{subtype}\" gomb folyamatosan nyomva (alternat\u00edv m\u00f3d)", + "remote_button_alt_long_release": "A \u201e{subtype}\u201d gomb elenged\u00e9se hossz\u00fa megnyom\u00e1st k\u00f6vet\u0151en (alternat\u00edv m\u00f3d)", + "remote_button_alt_quadruple_press": "A \u201e{subtype}\u201d gombra n\u00e9gyszer kattintottak (alternat\u00edv m\u00f3d)", + "remote_button_alt_quintuple_press": "\"{subtype}\" gombra \u00f6tsz\u00f6r kattintottak (alternat\u00edv m\u00f3d)", + "remote_button_alt_short_press": "\u201e{subtype}\u201d gomb lenyomva (alternat\u00edv m\u00f3d)", + "remote_button_alt_short_release": "A \"{subtype}\" gomb elengedett (alternat\u00edv m\u00f3d)", + "remote_button_alt_triple_press": "A \u201e{subtype}\u201d gombra h\u00e1romszor kattintottak (alternat\u00edv m\u00f3d)", + "remote_button_double_press": "\"{subtype}\" gombra k\u00e9tszer kattintottak", + "remote_button_long_press": "A \"{subtype}\" gomb folyamatosan lenyomva", + "remote_button_long_release": "A \"{subtype}\" gomb hossz\u00fa megnyom\u00e1s ut\u00e1n elengedve", + "remote_button_quadruple_press": "\"{subtype}\" gombra n\u00e9gyszer kattintottak", + "remote_button_quintuple_press": "\"{subtype}\" gombra \u00f6tsz\u00f6r kattintottak", + "remote_button_short_press": "\"{subtype}\" gomb lenyomva", + "remote_button_short_release": "\"{subtype}\" gomb elengedve", + "remote_button_triple_press": "\"{subtype}\" gombra h\u00e1romszor kattintottak" } } } \ No newline at end of file diff --git a/homeassistant/components/zha/translations/nl.json b/homeassistant/components/zha/translations/nl.json index 9c63b8989cb..9d285499ba1 100644 --- a/homeassistant/components/zha/translations/nl.json +++ b/homeassistant/components/zha/translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Dit apparaat is niet een zha-apparaat.", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Wilt u {name} instellen?" + }, "pick_radio": { "data": { "radio_type": "Radio type" @@ -70,7 +74,7 @@ "face_4": "met gezicht 4 geactiveerd", "face_5": "met gezicht 5 geactiveerd", "face_6": "met gezicht 6 geactiveerd", - "face_any": "Met elk/opgegeven gezicht (en) geactiveerd", + "face_any": "Met elk/opgegeven gezicht(en) geactiveerd", "left": "Links", "open": "Open", "right": "Rechts", diff --git a/homeassistant/components/zha/translations/nn.json b/homeassistant/components/zha/translations/nn.json index 2e607435b7e..9e9b677ddc1 100644 --- a/homeassistant/components/zha/translations/nn.json +++ b/homeassistant/components/zha/translations/nn.json @@ -1,6 +1,9 @@ { "config": { "step": { + "port_config": { + "title": "Innstillinger" + }, "user": { "title": "ZHA" } diff --git a/homeassistant/components/zha/translations/no.json b/homeassistant/components/zha/translations/no.json index efa6c06a067..64986b7f6da 100644 --- a/homeassistant/components/zha/translations/no.json +++ b/homeassistant/components/zha/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Denne enheten er ikke en zha -enhet", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Vil du konfigurere {name}?" + }, "pick_radio": { "data": { "radio_type": "Radio type" diff --git a/homeassistant/components/zha/translations/pl.json b/homeassistant/components/zha/translations/pl.json index 8c726fc349f..40a5257335f 100644 --- a/homeassistant/components/zha/translations/pl.json +++ b/homeassistant/components/zha/translations/pl.json @@ -1,6 +1,7 @@ { "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." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name}?" + }, "pick_radio": { "data": { "radio_type": "Typ radia" diff --git a/homeassistant/components/zha/translations/ru.json b/homeassistant/components/zha/translations/ru.json index 8644cdbc03b..17d95dbd7e8 100644 --- a/homeassistant/components/zha/translations/ru.json +++ b/homeassistant/components/zha/translations/ru.json @@ -1,6 +1,7 @@ { "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." }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?" + }, "pick_radio": { "data": { "radio_type": "\u0422\u0438\u043f \u0440\u0430\u0434\u0438\u043e\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" diff --git a/homeassistant/components/zha/translations/zh-Hant.json b/homeassistant/components/zha/translations/zh-Hant.json index d219e311791..e08adf98527 100644 --- a/homeassistant/components/zha/translations/zh-Hant.json +++ b/homeassistant/components/zha/translations/zh-Hant.json @@ -1,6 +1,7 @@ { "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" }, "error": { @@ -8,6 +9,9 @@ }, "flow_title": "{name}", "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f" + }, "pick_radio": { "data": { "radio_type": "\u7121\u7dda\u96fb\u985e\u578b" diff --git a/homeassistant/components/zodiac/sensor.py b/homeassistant/components/zodiac/sensor.py index 80a4f782915..b337dda1db0 100644 --- a/homeassistant/components/zodiac/sensor.py +++ b/homeassistant/components/zodiac/sensor.py @@ -196,7 +196,7 @@ class ZodiacSensor(SensorEntity): return "zodiac__sign" @property - def state(self) -> str: + def native_value(self) -> str: """Return the state of the device.""" return self._state diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 8ab0e9b2703..d4474d793ab 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -30,6 +30,7 @@ from homeassistant.helpers import ( service, storage, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.location import distance @@ -176,7 +177,7 @@ class ZoneStorageCollection(collection.StorageCollection): return {**data, **update_data} -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up configured zones as well as Home Assistant zone if necessary.""" component = entity_component.EntityComponent(_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() diff --git a/homeassistant/components/zone/translations/lt.json b/homeassistant/components/zone/translations/lt.json new file mode 100644 index 00000000000..d7127048a63 --- /dev/null +++ b/homeassistant/components/zone/translations/lt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "init": { + "data": { + "name": "Pavadinimas" + } + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 701f4b490d3..d392901b633 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -71,7 +71,7 @@ class ZMSensorMonitors(SensorEntity): return f"{self._monitor.name} Status" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -107,12 +107,12 @@ class ZMSensorEvents(SensorEntity): return f"{self._monitor.name} {self.time_period.title}" @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return "Events" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @@ -136,7 +136,7 @@ class ZMSensorRunState(SensorEntity): return "Run State" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/zoneminder/translations/zh-Hans.json b/homeassistant/components/zoneminder/translations/zh-Hans.json index a5f4ff11f09..8f3265d4344 100644 --- a/homeassistant/components/zoneminder/translations/zh-Hans.json +++ b/homeassistant/components/zoneminder/translations/zh-Hans.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u81f3 ZoneMinder \u670d\u52a1\u5668\u3002" + }, + "error": { + "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u81f3 ZoneMinder \u670d\u52a1\u5668\u3002" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/zwave/sensor.py b/homeassistant/components/zwave/sensor.py index d973e52ff92..75046c2f9d8 100644 --- a/homeassistant/components/zwave/sensor.py +++ b/homeassistant/components/zwave/sensor.py @@ -56,12 +56,12 @@ class ZWaveSensor(ZWaveDeviceEntity, SensorEntity): return True @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit of measurement the value is expressed in.""" return self._units @@ -70,7 +70,7 @@ class ZWaveMultilevelSensor(ZWaveSensor): """Representation of a multi level sensor Z-Wave sensor.""" @property - def state(self): + def native_value(self): """Return the state of the sensor.""" if self._units in ("C", "F"): return round(self._state, 1) @@ -87,7 +87,7 @@ class ZWaveMultilevelSensor(ZWaveSensor): return None @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" if self._units == "C": return TEMP_CELSIUS diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 6cd0104e298..f38594c1594 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -28,6 +28,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .addon import AddonError, AddonManager, AddonState, get_addon_manager from .api import async_register_api @@ -79,7 +80,7 @@ DATA_CONNECT_FAILED_LOGGED = "connect_failed_logged" DATA_INVALID_SERVER_VERSION_LOGGED = "invalid_server_version_logged" -async def async_setup(hass: HomeAssistant, config: dict) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" hass.data[DOMAIN] = {} return True @@ -144,7 +145,7 @@ 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): + 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 @@ -547,7 +548,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) return try: - await addon_manager.async_create_snapshot() + await addon_manager.async_create_backup() except AddonError as err: LOGGER.error(err) return diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index a0caaa15488..29ae887b4bc 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -8,7 +8,7 @@ from functools import partial from typing import Any, Callable, TypeVar, cast from homeassistant.components.hassio import ( - async_create_snapshot, + async_create_backup, async_get_addon_discovery_info, async_get_addon_info, async_install_addon, @@ -202,7 +202,7 @@ class AddonManager: if not addon_info.update_available: return - await self.async_create_snapshot() + await self.async_create_backup() await async_update_addon(self._hass, ADDON_SLUG) @callback @@ -289,14 +289,14 @@ class AddonManager: ) return self._start_task - @api_error("Failed to create a snapshot of the Z-Wave JS add-on.") - async def async_create_snapshot(self) -> None: - """Create a partial snapshot of the Z-Wave JS add-on.""" + @api_error("Failed to create a backup of the Z-Wave JS add-on.") + async def async_create_backup(self) -> None: + """Create a partial backup of the Z-Wave JS add-on.""" addon_info = await self.async_get_addon_info() name = f"addon_{ADDON_SLUG}_{addon_info.version}" - LOGGER.debug("Creating snapshot: %s", name) - await async_create_snapshot( + LOGGER.debug("Creating backup: %s", name) + await async_create_backup( self._hass, {"name": name, "addons": [ADDON_SLUG]}, partial=True, diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index a55ae47b935..6cd0ea4fe44 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -10,7 +10,7 @@ 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, LogLevel +from zwave_js_server.const import CommandClass, InclusionStrategy, LogLevel from zwave_js_server.exceptions import ( BaseZwaveJSServerError, FailedCommand, @@ -386,7 +386,11 @@ async def websocket_add_node( ) -> None: """Add a node to the Z-Wave network.""" controller = client.driver.controller - include_non_secure = not msg[SECURE] + + if msg[SECURE]: + inclusion_strategy = InclusionStrategy.SECURITY_S0 + else: + inclusion_strategy = InclusionStrategy.INSECURE @callback def async_cleanup() -> None: @@ -454,7 +458,7 @@ async def websocket_add_node( ), ] - result = await controller.async_begin_inclusion(include_non_secure) + result = await controller.async_begin_inclusion(inclusion_strategy) connection.send_result( msg[ID], result, @@ -594,9 +598,13 @@ async def websocket_replace_failed_node( ) -> None: """Replace a failed node with a new node.""" controller = client.driver.controller - include_non_secure = not msg[SECURE] node_id = msg[NODE_ID] + if msg[SECURE]: + inclusion_strategy = InclusionStrategy.SECURITY_S0 + else: + inclusion_strategy = InclusionStrategy.INSECURE + @callback def async_cleanup() -> None: """Remove signal listeners.""" @@ -677,7 +685,7 @@ async def websocket_replace_failed_node( ), ] - result = await controller.async_replace_failed_node(node_id, include_non_secure) + result = await controller.async_replace_failed_node(node_id, inclusion_strategy) connection.send_result( msg[ID], result, diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 1621e87cfab..1ec5ccbcc01 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -4,14 +4,14 @@ from __future__ import annotations from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, THERMOSTAT_MODE_PROPERTY, THERMOSTAT_MODE_SETPOINT_MAP, THERMOSTAT_MODES, THERMOSTAT_OPERATING_STATE_PROPERTY, THERMOSTAT_SETPOINT_PROPERTY, - CommandClass, ThermostatMode, ThermostatOperatingState, ThermostatSetpointType, diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index ced8b2c68cb..55266d02389 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -12,8 +12,9 @@ import voluptuous as vol from zwave_js_server.version import VersionInfo, get_server_version from homeassistant import config_entries, exceptions +from homeassistant.components import usb from homeassistant.components.hassio import is_hassio -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import ( AbortFlow, @@ -286,6 +287,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): """Set up flow instance.""" super().__init__() self.use_addon = False + self._title: str | None = None @property def flow_manager(self) -> config_entries.ConfigEntriesFlowManager: @@ -309,6 +311,64 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_manual() + async def async_step_usb(self, discovery_info: dict[str, str]) -> FlowResult: + """Handle USB Discovery.""" + if not is_hassio(self.hass): + return self.async_abort(reason="discovery_requires_supervisor") + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + + vid = discovery_info["vid"] + pid = discovery_info["pid"] + serial_number = discovery_info["serial_number"] + device = discovery_info["device"] + manufacturer = discovery_info["manufacturer"] + description = discovery_info["description"] + # The Nortek sticks are a special case since they + # have a Z-Wave and a Zigbee radio. We need to reject + # the Zigbee radio. + if vid == "10C4" and pid == "8A2A" and "Z-Wave" not in description: + return self.async_abort(reason="not_zwave_device") + # Zooz uses this vid/pid, but so do 2652 sticks + if vid == "10C4" and pid == "EA60" and "2652" in description: + return self.async_abort(reason="not_zwave_device") + + addon_info = await self._async_get_addon_info() + if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.NOT_RUNNING): + return self.async_abort(reason="already_configured") + + await self.async_set_unique_id( + f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" + ) + self._abort_if_unique_id_configured() + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + self.usb_path = dev_path + self._title = usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + self.context["title_placeholders"] = {CONF_NAME: self._title} + return await self.async_step_usb_confirm() + + async def async_step_usb_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle USB Discovery confirmation.""" + if user_input is None: + return self.async_show_form( + step_id="usb_confirm", + description_placeholders={CONF_NAME: self._title}, + data_schema=vol.Schema({}), + ) + + return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -352,6 +412,9 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): This flow is triggered by the Z-Wave JS add-on. """ + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" try: version_info = await async_get_version_info(self.hass, self.ws_address) @@ -422,7 +485,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_start_addon() - usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") + 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 "") data_schema = vol.Schema( @@ -446,7 +509,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id: + if not self.unique_id or self.context["source"] == config_entries.SOURCE_USB: if not self.version_info: try: self.version_info = await async_get_version_info( @@ -471,6 +534,10 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): @callback def _async_create_entry_from_vars(self) -> FlowResult: """Return a config entry for the flow.""" + # Abort any other flows that may be in progress + for progress in self._async_in_progress(): + self.hass.config_entries.flow.async_abort(progress["flow_id"]) + return self.async_create_entry( title=TITLE, data={ diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 7848af146b5..e4486a681e1 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -48,6 +48,13 @@ ATTR_OPTIONS = "options" ATTR_NODE = "node" ATTR_ZWAVE_VALUE = "zwave_value" +# automation trigger attributes +ATTR_PREVIOUS_VALUE = "previous_value" +ATTR_PREVIOUS_VALUE_RAW = "previous_value_raw" +ATTR_CURRENT_VALUE = "current_value" +ATTR_CURRENT_VALUE_RAW = "current_value_raw" +ATTR_DESCRIPTION = "description" + # service constants SERVICE_SET_VALUE = "set_value" SERVICE_RESET_METER = "reset_meter" @@ -68,5 +75,26 @@ ATTR_REFRESH_ALL_VALUES = "refresh_all_values" ATTR_BROADCAST = "broadcast" # meter reset ATTR_METER_TYPE = "meter_type" +ATTR_METER_TYPE_NAME = "meter_type_name" ADDON_SLUG = "core_zwave_js" + +# Sensor entity description constants +ENTITY_DESC_KEY_BATTERY = "battery" +ENTITY_DESC_KEY_CURRENT = "current" +ENTITY_DESC_KEY_VOLTAGE = "voltage" +ENTITY_DESC_KEY_ENERGY_MEASUREMENT = "energy_measurement" +ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING = "energy_total_increasing" +ENTITY_DESC_KEY_POWER = "power" +ENTITY_DESC_KEY_POWER_FACTOR = "power_factor" +ENTITY_DESC_KEY_CO = "co" +ENTITY_DESC_KEY_CO2 = "co2" +ENTITY_DESC_KEY_HUMIDITY = "humidity" +ENTITY_DESC_KEY_ILLUMINANCE = "illuminance" +ENTITY_DESC_KEY_PRESSURE = "pressure" +ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength" +ENTITY_DESC_KEY_TEMPERATURE = "temperature" +ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" +ENTITY_DESC_KEY_TIMESTAMP = "timestamp" +ENTITY_DESC_KEY_MEASUREMENT = "measurement" +ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index f8e575521dc..7fceaf64c0e 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,7 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import BarrierState +from zwave_js_server.const.command_class.barrior_operator import BarrierState from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index b419230a0bd..f17654f184a 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -4,9 +4,12 @@ from __future__ import annotations from typing import cast import voluptuous as vol -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import CommandClass from zwave_js_server.model.value import ConfigurationValue +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -22,7 +25,14 @@ from .const import ( ATTR_PROPERTY_KEY, ATTR_VALUE, ) -from .helpers import async_get_node_from_device_id, get_zwave_value_from_config +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" @@ -67,10 +77,21 @@ VALUE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( } ) -CONDITION_SCHEMA = vol.Any( - NODE_STATUS_CONDITION_SCHEMA, - CONFIG_PARAMETER_CONDITION_SCHEMA, - VALUE_CONDITION_SCHEMA, +TYPE_SCHEMA_MAP = { + NODE_STATUS_TYPE: NODE_STATUS_CONDITION_SCHEMA, + CONFIG_PARAMETER_TYPE: CONFIG_PARAMETER_CONDITION_SCHEMA, + VALUE_TYPE: VALUE_CONDITION_SCHEMA, +} + + +CONDITION_TYPE_SCHEMA = vol.Schema( + {vol.Required(CONF_TYPE): vol.In(TYPE_SCHEMA_MAP)}, extra=vol.ALLOW_EXTRA +) + +CONDITION_SCHEMA = vol.All( + remove_keys_with_empty_values, + CONDITION_TYPE_SCHEMA, + check_type_schema_map(TYPE_SCHEMA_MAP), ) @@ -79,9 +100,18 @@ async def async_validate_condition_config( ) -> ConfigType: """Validate config.""" config = CONDITION_SCHEMA(config) + + # We return early if the config entry for this device is not ready because we can't + # validate the value without knowing the state of the device + if async_is_device_config_entry_not_loaded(hass, config[CONF_DEVICE_ID]): + return config + if config[CONF_TYPE] == VALUE_TYPE: - node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) - get_zwave_value_from_config(node, config) + try: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig(err.msg) from err return config @@ -174,30 +204,24 @@ 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] - config_value = cast(ConfigurationValue, node.values[value_id]) - min_ = config_value.metadata.min - max_ = config_value.metadata.max - - if config_value.configuration_value_type in ( - ConfigurationValueType.RANGE, - ConfigurationValueType.MANUAL_ENTRY, - ): - value_schema = vol.Range(min=min_, max=max_) - elif config_value.configuration_value_type == ConfigurationValueType.ENUMERATED: - value_schema = vol.In( - {int(k): v for k, v in config_value.metadata.states.items()} - ) - else: + value_schema = get_value_state_schema(node.values[value_id]) + if not value_schema: return {} return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} if config[CONF_TYPE] == VALUE_TYPE: + # Only show command classes on this node and exclude Configuration CC since it + # is already covered return { "extra_fields": vol.Schema( { vol.Required(ATTR_COMMAND_CLASS): vol.In( - {cc.value: cc.name for cc in CommandClass} + { + CommandClass(cc.id).value: cc.name + for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] + if cc.id != CommandClass.CONFIGURATION + } ), vol.Required(ATTR_PROPERTY): cv.string, vol.Optional(ATTR_PROPERTY_KEY): cv.string, diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 6d1b611d14f..7ed13ce2b98 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -1,11 +1,16 @@ """Provides device triggers for Z-Wave JS.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from zwave_js_server.const import CommandClass from homeassistant.components.automation import AutomationActionType from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.homeassistant.triggers import event, state from homeassistant.const import ( CONF_DEVICE_ID, @@ -23,6 +28,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType +from . import trigger from .const import ( ATTR_COMMAND_CLASS, ATTR_DATA_TYPE, @@ -43,11 +49,20 @@ from .const import ( from .helpers import ( async_get_node_from_device_id, async_get_node_status_sensor_entity_id, + async_is_device_config_entry_not_loaded, + check_type_schema_map, + copy_available_params, + get_value_state_schema, get_zwave_value_from_config, + remove_keys_with_empty_values, +) +from .triggers.value_updated import ( + ATTR_FROM, + ATTR_TO, + PLATFORM_TYPE as VALUE_UPDATED_PLATFORM_TYPE, ) CONF_SUBTYPE = "subtype" -CONF_VALUE_ID = "value_id" # Trigger types ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control" @@ -55,8 +70,19 @@ NOTIFICATION_NOTIFICATION = "event.notification.notification" BASIC_VALUE_NOTIFICATION = "event.value_notification.basic" CENTRAL_SCENE_VALUE_NOTIFICATION = "event.value_notification.central_scene" SCENE_ACTIVATION_VALUE_NOTIFICATION = "event.value_notification.scene_activation" +CONFIG_PARAMETER_VALUE_UPDATED = f"{VALUE_UPDATED_PLATFORM_TYPE}.config_parameter" +VALUE_VALUE_UPDATED = f"{VALUE_UPDATED_PLATFORM_TYPE}.value" NODE_STATUS = "state.node_status" +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + cv.boolean, + cv.string, +) + + NOTIFICATION_EVENT_CC_MAPPINGS = ( (ENTRY_CONTROL_NOTIFICATION, CommandClass.ENTRY_CONTROL), (NOTIFICATION_NOTIFICATION, CommandClass.NOTIFICATION), @@ -90,7 +116,7 @@ ENTRY_CONTROL_NOTIFICATION_SCHEMA = BASE_EVENT_SCHEMA.extend( BASE_VALUE_NOTIFICATION_EVENT_SCHEMA = BASE_EVENT_SCHEMA.extend( { vol.Required(ATTR_PROPERTY): vol.Any(int, str), - vol.Required(ATTR_PROPERTY_KEY): vol.Any(None, int, str), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(int, str), vol.Required(ATTR_ENDPOINT): vol.Coerce(int), vol.Optional(ATTR_VALUE): vol.Coerce(int), vol.Required(CONF_SUBTYPE): cv.string, @@ -135,17 +161,90 @@ NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend( } ) -TRIGGER_SCHEMA = vol.Any( - ENTRY_CONTROL_NOTIFICATION_SCHEMA, - NOTIFICATION_NOTIFICATION_SCHEMA, - BASIC_VALUE_NOTIFICATION_SCHEMA, - CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA, - SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA, - NODE_STATUS_SCHEMA, +# zwave_js.value_updated based trigger schemas +BASE_VALUE_UPDATED_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + 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(None, vol.Coerce(int), str), + vol.Optional(ATTR_ENDPOINT): vol.Any(None, vol.Coerce(int)), + vol.Optional(ATTR_FROM): VALUE_SCHEMA, + vol.Optional(ATTR_TO): VALUE_SCHEMA, + } +) + +CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA = BASE_VALUE_UPDATED_SCHEMA.extend( + { + vol.Required(CONF_TYPE): CONFIG_PARAMETER_VALUE_UPDATED, + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +VALUE_VALUE_UPDATED_SCHEMA = BASE_VALUE_UPDATED_SCHEMA.extend( + { + vol.Required(CONF_TYPE): VALUE_VALUE_UPDATED, + } +) + +TYPE_SCHEMA_MAP = { + ENTRY_CONTROL_NOTIFICATION: ENTRY_CONTROL_NOTIFICATION_SCHEMA, + NOTIFICATION_NOTIFICATION: NOTIFICATION_NOTIFICATION_SCHEMA, + BASIC_VALUE_NOTIFICATION: BASIC_VALUE_NOTIFICATION_SCHEMA, + CENTRAL_SCENE_VALUE_NOTIFICATION: CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA, + SCENE_ACTIVATION_VALUE_NOTIFICATION: SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA, + CONFIG_PARAMETER_VALUE_UPDATED: CONFIG_PARAMETER_VALUE_UPDATED_SCHEMA, + VALUE_VALUE_UPDATED: VALUE_VALUE_UPDATED_SCHEMA, + NODE_STATUS: NODE_STATUS_SCHEMA, +} + + +TRIGGER_TYPE_SCHEMA = vol.Schema( + {vol.Required(CONF_TYPE): vol.In(TYPE_SCHEMA_MAP)}, extra=vol.ALLOW_EXTRA +) + +TRIGGER_SCHEMA = vol.All( + remove_keys_with_empty_values, + TRIGGER_TYPE_SCHEMA, + check_type_schema_map(TYPE_SCHEMA_MAP), ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + # We return early if the config entry for this device is not ready because we can't + # validate the value without knowing the state of the device + if async_is_device_config_entry_not_loaded(hass, config[CONF_DEVICE_ID]): + return config + + trigger_type = config[CONF_TYPE] + if get_trigger_platform_from_type(trigger_type) == VALUE_UPDATED_PLATFORM_TYPE: + try: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig(err.msg) from err + + return config + + +def get_trigger_platform_from_type(trigger_type: str) -> str: + """Get trigger platform from Z-Wave JS trigger type.""" + trigger_split = trigger_type.split(".") + # Our convention for trigger types is to have the trigger type at the beginning + # delimited by a `.`. For zwave_js triggers, there is a `.` in the name + trigger_platform = trigger_split[0] + if trigger_platform == DOMAIN: + return ".".join(trigger_split[:2]) + return trigger_platform + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for Z-Wave JS devices.""" dev_reg = device_registry.async_get(hass) node = async_get_node_from_device_id(hass, device_id, dev_reg) @@ -233,18 +332,28 @@ async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: ] ) + # Generic value update event trigger + triggers.append({**base_trigger, CONF_TYPE: VALUE_VALUE_UPDATED}) + + # Config parameter value update event triggers + triggers.extend( + [ + { + **base_trigger, + CONF_TYPE: CONFIG_PARAMETER_VALUE_UPDATED, + ATTR_PROPERTY: config_value.property_, + ATTR_PROPERTY_KEY: config_value.property_key, + ATTR_ENDPOINT: config_value.endpoint, + ATTR_COMMAND_CLASS: config_value.command_class, + CONF_SUBTYPE: f"{config_value.value_id} ({config_value.property_name})", + } + for config_value in node.get_configuration_values().values() + ] + ) + return triggers -def copy_available_params( - input_dict: dict, output_dict: dict, params: list[str] -) -> None: - """Copy available params from input into output.""" - for param in params: - if (val := input_dict.get(param)) not in ("", None): - output_dict[param] = val - - async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, @@ -253,20 +362,20 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_type = config[CONF_TYPE] - trigger_platform = trigger_type.split(".")[0] - - event_data = {CONF_DEVICE_ID: config[CONF_DEVICE_ID]} - event_config = { - event.CONF_PLATFORM: "event", - event.CONF_EVENT_DATA: event_data, - } - - if ATTR_COMMAND_CLASS in config: - event_data[ATTR_COMMAND_CLASS] = config[ATTR_COMMAND_CLASS] + trigger_platform = get_trigger_platform_from_type(trigger_type) # Take input data from automation trigger UI and add it to the trigger we are # attaching to if trigger_platform == "event": + event_data = {CONF_DEVICE_ID: config[CONF_DEVICE_ID]} + event_config = { + event.CONF_PLATFORM: "event", + event.CONF_EVENT_DATA: event_data, + } + + if ATTR_COMMAND_CLASS in config: + event_data[ATTR_COMMAND_CLASS] = config[ATTR_COMMAND_CLASS] + if trigger_type == ENTRY_CONTROL_NOTIFICATION: event_config[event.CONF_EVENT_TYPE] = ZWAVE_JS_NOTIFICATION_EVENT copy_available_params(config, event_data, [ATTR_EVENT_TYPE, ATTR_DATA_TYPE]) @@ -296,19 +405,46 @@ async def async_attach_trigger( hass, event_config, action, automation_info, platform_type="device" ) - state_config = {state.CONF_PLATFORM: "state"} + if trigger_platform == "state": + if trigger_type == NODE_STATUS: + state_config = {state.CONF_PLATFORM: "state"} - if trigger_platform == "state" and trigger_type == NODE_STATUS: - state_config[state.CONF_ENTITY_ID] = config[CONF_ENTITY_ID] - copy_available_params( - config, state_config, [state.CONF_FOR, state.CONF_FROM, state.CONF_TO] - ) + state_config[state.CONF_ENTITY_ID] = config[CONF_ENTITY_ID] + copy_available_params( + config, state_config, [state.CONF_FOR, state.CONF_FROM, state.CONF_TO] + ) + else: + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") state_config = state.TRIGGER_SCHEMA(state_config) return await state.async_attach_trigger( hass, state_config, action, automation_info, platform_type="device" ) + if trigger_platform == VALUE_UPDATED_PLATFORM_TYPE: + zwave_js_config = { + state.CONF_PLATFORM: trigger_platform, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + copy_available_params( + config, + zwave_js_config, + [ + ATTR_COMMAND_CLASS, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_ENDPOINT, + ATTR_FROM, + ATTR_TO, + ], + ) + zwave_js_config = await trigger.async_validate_trigger_config( + hass, zwave_js_config + ) + return await trigger.async_attach_trigger( + hass, zwave_js_config, action, automation_info + ) + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") @@ -316,12 +452,12 @@ async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List trigger capabilities.""" + trigger_type = config[CONF_TYPE] + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) - value = ( - get_zwave_value_from_config(node, config) if ATTR_PROPERTY in config else None - ) + # Add additional fields to the automation trigger UI - if config[CONF_TYPE] == NOTIFICATION_NOTIFICATION: + if trigger_type == NOTIFICATION_NOTIFICATION: return { "extra_fields": vol.Schema( { @@ -333,7 +469,7 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] == ENTRY_CONTROL_NOTIFICATION: + if trigger_type == ENTRY_CONTROL_NOTIFICATION: return { "extra_fields": vol.Schema( { @@ -343,7 +479,7 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] == NODE_STATUS: + if trigger_type == NODE_STATUS: return { "extra_fields": vol.Schema( { @@ -354,19 +490,52 @@ async def async_get_trigger_capabilities( ) } - if config[CONF_TYPE] in ( + if trigger_type in ( BASIC_VALUE_NOTIFICATION, CENTRAL_SCENE_VALUE_NOTIFICATION, SCENE_ACTIVATION_VALUE_NOTIFICATION, ): - if value.metadata.states: - value_schema = vol.In({int(k): v for k, v in value.metadata.states.items()}) - else: - value_schema = vol.All( - vol.Coerce(int), - vol.Range(min=value.metadata.min, max=value.metadata.max), - ) + value_schema = get_value_state_schema(get_zwave_value_from_config(node, config)) + + # We should never get here, but just in case we should add a guard + if not value_schema: + return {} return {"extra_fields": vol.Schema({vol.Optional(ATTR_VALUE): value_schema})} + if trigger_type == CONFIG_PARAMETER_VALUE_UPDATED: + value_schema = get_value_state_schema(get_zwave_value_from_config(node, config)) + if not value_schema: + return {} + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_FROM): value_schema, + vol.Optional(ATTR_TO): value_schema, + } + ) + } + + if trigger_type == VALUE_VALUE_UPDATED: + # Only show command classes on this node and exclude Configuration CC since it + # is already covered + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_COMMAND_CLASS): vol.In( + { + CommandClass(cc.id).value: cc.name + for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] + if cc.id != CommandClass.CONFIGURATION + } + ), + vol.Required(ATTR_PROPERTY): cv.string, + vol.Optional(ATTR_PROPERTY_KEY): cv.string, + vol.Optional(ATTR_ENDPOINT): cv.string, + vol.Optional(ATTR_FROM): cv.string, + vol.Optional(ATTR_TO): cv.string, + } + ) + } + return {} diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 588b4c76472..d5af1c072ee 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -6,16 +6,23 @@ from dataclasses import asdict, dataclass, field from typing import Any from awesomeversion import AwesomeVersion -from zwave_js_server.const import THERMOSTAT_CURRENT_TEMP_PROPERTY, CommandClass +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( + THERMOSTAT_CURRENT_TEMP_PROPERTY, +) +from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry +from .const import LOGGER from .discovery_data_template import ( BaseDiscoverySchemaDataTemplate, DynamicCurrentTempClimateDataTemplate, + NumericSensorDataTemplate, ZwaveValueID, ) @@ -59,14 +66,14 @@ class ZwaveDiscoveryInfo: assumed_state: bool # the home assistant platform for which an entity should be created platform: str + # helper data to use in platform setup + platform_data: Any + # additional values that need to be watched by entity + additional_value_ids_to_watch: set[str] # hint for the platform about this discovered entity platform_hint: str | None = "" # data template to use in platform logic platform_data_template: BaseDiscoverySchemaDataTemplate | None = None - # helper data to use in platform setup - platform_data: dict[str, Any] | None = None - # additional values that need to be watched by entity - additional_value_ids_to_watch: set[str] | None = None # bool to specify whether entity should be enabled by default entity_registry_enabled_default: bool = True @@ -359,24 +366,11 @@ DISCOVERY_SCHEMAS = [ get_config_parameter_discovery_schema( property_name={"Door lock mode"}, device_class_generic={"Entry Control"}, - device_class_specific={ - "Door Lock", - "Advanced Door Lock", - "Secure Keypad Door Lock", - "Secure Lockbox", - }, ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks ZWaveDiscoverySchema( platform="lock", - device_class_generic={"Entry Control"}, - device_class_specific={ - "Door Lock", - "Advanced Door Lock", - "Secure Keypad Door Lock", - "Secure Lockbox", - }, primary_value=ZWaveValueDiscoverySchema( command_class={ CommandClass.LOCK, @@ -390,13 +384,6 @@ DISCOVERY_SCHEMAS = [ ZWaveDiscoverySchema( platform="binary_sensor", hint="property", - device_class_generic={"Entry Control"}, - device_class_specific={ - "Door Lock", - "Advanced Door Lock", - "Secure Keypad Door Lock", - "Secure Lockbox", - }, primary_value=ZWaveValueDiscoverySchema( command_class={ CommandClass.LOCK, @@ -507,6 +494,7 @@ DISCOVERY_SCHEMAS = [ }, type={"number"}, ), + data_template=NumericSensorDataTemplate(), ), ZWaveDiscoverySchema( platform="sensor", @@ -515,6 +503,7 @@ DISCOVERY_SCHEMAS = [ command_class={CommandClass.INDICATOR}, type={"number"}, ), + data_template=NumericSensorDataTemplate(), entity_registry_enabled_default=False, ), # Meter sensors for Meter CC @@ -528,6 +517,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, property={"value"}, ), + data_template=NumericSensorDataTemplate(), ), # special list sensors (Notification CC) ZWaveDiscoverySchema( @@ -542,10 +532,10 @@ DISCOVERY_SCHEMAS = [ allow_multi=True, entity_registry_enabled_default=False, ), - # sensor for basic CC + # number for Basic CC ZWaveDiscoverySchema( - platform="sensor", - hint="numeric_sensor", + platform="number", + hint="Basic", primary_value=ZWaveValueDiscoverySchema( command_class={ CommandClass.BASIC, @@ -553,6 +543,16 @@ DISCOVERY_SCHEMAS = [ type={"number"}, property={"currentValue"}, ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={ + CommandClass.BASIC, + }, + type={"number"}, + property={"targetValue"}, + ) + ], + data_template=NumericSensorDataTemplate(), entity_registry_enabled_default=False, ), # binary switches @@ -633,11 +633,47 @@ DISCOVERY_SCHEMAS = [ platform="siren", primary_value=SIREN_TONE_SCHEMA, ), + # select + # siren default tone + ZWaveDiscoverySchema( + platform="select", + hint="Default tone", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SOUND_SWITCH}, + property={"defaultToneId"}, + type={"number"}, + ), + required_values=[SIREN_TONE_SCHEMA], + ), + # number + # siren default volume + ZWaveDiscoverySchema( + platform="number", + hint="volume", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SOUND_SWITCH}, + property={"defaultVolume"}, + type={"number"}, + ), + required_values=[SIREN_TONE_SCHEMA], + ), + # select + # protection CC + ZWaveDiscoverySchema( + platform="select", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.PROTECTION}, + property={"local", "rf"}, + type={"number"}, + ), + ), ] @callback -def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None, None]: +def async_discover_values( + node: ZwaveNode, device: DeviceEntry +) -> 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: @@ -722,9 +758,19 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None # resolve helper data from template resolved_data = None - additional_value_ids_to_watch = None + additional_value_ids_to_watch = set() if schema.data_template: - resolved_data = schema.data_template.resolve_data(value) + 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 ) diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 7962b6b1c05..974cd2bfa44 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -1,12 +1,85 @@ -"""Data template classes for discovery used to generate device specific data for setup.""" +"""Data template classes for discovery used to generate additional data for setup.""" from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass from typing import Any +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.meter import ( + CURRENT_METER_TYPES, + ENERGY_TOTAL_INCREASING_METER_TYPES, + POWER_FACTOR_METER_TYPES, + POWER_METER_TYPES, + VOLTAGE_METER_TYPES, + ElectricScale, + MeterScaleType, +) +from zwave_js_server.const.command_class.multilevel_sensor import ( + CO2_SENSORS, + CO_SENSORS, + CURRENT_SENSORS, + ENERGY_MEASUREMENT_SENSORS, + HUMIDITY_SENSORS, + ILLUMINANCE_SENSORS, + POWER_SENSORS, + PRESSURE_SENSORS, + SIGNAL_STRENGTH_SENSORS, + TEMPERATURE_SENSORS, + TIMESTAMP_SENSORS, + VOLTAGE_SENSORS, + MultilevelSensorType, +) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue, get_value_id +from zwave_js_server.util.command_class import ( + get_meter_scale_type, + get_multilevel_sensor_type, +) + +from .const import ( + ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_CO, + ENTITY_DESC_KEY_CO2, + ENTITY_DESC_KEY_CURRENT, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + ENTITY_DESC_KEY_HUMIDITY, + ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_MEASUREMENT, + ENTITY_DESC_KEY_POWER, + ENTITY_DESC_KEY_POWER_FACTOR, + ENTITY_DESC_KEY_PRESSURE, + ENTITY_DESC_KEY_SIGNAL_STRENGTH, + ENTITY_DESC_KEY_TARGET_TEMPERATURE, + ENTITY_DESC_KEY_TEMPERATURE, + ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_TOTAL_INCREASING, + ENTITY_DESC_KEY_VOLTAGE, +) + +METER_DEVICE_CLASS_MAP: dict[str, set[MeterScaleType]] = { + ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES, + ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_METER_TYPES, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ENERGY_TOTAL_INCREASING_METER_TYPES, + ENTITY_DESC_KEY_POWER: POWER_METER_TYPES, + ENTITY_DESC_KEY_POWER_FACTOR: POWER_FACTOR_METER_TYPES, +} + +MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, set[MultilevelSensorType]] = { + ENTITY_DESC_KEY_CO: CO_SENSORS, + ENTITY_DESC_KEY_CO2: CO2_SENSORS, + ENTITY_DESC_KEY_CURRENT: CURRENT_SENSORS, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ENERGY_MEASUREMENT_SENSORS, + ENTITY_DESC_KEY_HUMIDITY: HUMIDITY_SENSORS, + ENTITY_DESC_KEY_ILLUMINANCE: ILLUMINANCE_SENSORS, + ENTITY_DESC_KEY_POWER: POWER_SENSORS, + ENTITY_DESC_KEY_PRESSURE: PRESSURE_SENSORS, + ENTITY_DESC_KEY_SIGNAL_STRENGTH: SIGNAL_STRENGTH_SENSORS, + ENTITY_DESC_KEY_TEMPERATURE: TEMPERATURE_SENSORS, + ENTITY_DESC_KEY_TIMESTAMP: TIMESTAMP_SENSORS, + ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_SENSORS, +} @dataclass @@ -19,11 +92,10 @@ class ZwaveValueID: property_key: str | int | None = None -@dataclass class BaseDiscoverySchemaDataTemplate: """Base class for discovery schema data templates.""" - def resolve_data(self, value: ZwaveValue) -> dict[str, Any]: + def resolve_data(self, value: ZwaveValue) -> Any: """ Resolve helper class data for a discovered value. @@ -33,7 +105,7 @@ class BaseDiscoverySchemaDataTemplate: # pylint: disable=no-self-use return {} - def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue]: + def values_to_watch(self, resolved_data: Any) -> Iterable[ZwaveValue]: """ Return list of all ZwaveValues resolved by helper that should be watched. @@ -42,7 +114,7 @@ class BaseDiscoverySchemaDataTemplate: # pylint: disable=no-self-use return [] - def value_ids_to_watch(self, resolved_data: dict[str, Any]) -> set[str]: + def value_ids_to_watch(self, resolved_data: Any) -> set[str]: """ Return list of all Value IDs resolved by helper that should be watched. @@ -107,3 +179,45 @@ class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate): return lookup_table.get(lookup_key) return None + + +class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): + """Data template class for Z-Wave Sensor entities.""" + + def resolve_data(self, value: ZwaveValue) -> str | None: + """Resolve helper class data for a discovered value.""" + + if value.command_class == CommandClass.BATTERY: + return ENTITY_DESC_KEY_BATTERY + + if value.command_class == CommandClass.METER: + scale_type = get_meter_scale_type(value) + # We do this because even though these are energy scales, they don't meet + # the unit requirements for the energy device class. + if scale_type in ( + ElectricScale.PULSE_COUNT, + ElectricScale.KILOVOLT_AMPERE_HOUR, + ElectricScale.KILOVOLT_AMPERE_REACTIVE_HOUR, + ): + 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. + if scale_type == ElectricScale.KILOVOLT_AMPERE_REACTIVE: + return ENTITY_DESC_KEY_MEASUREMENT + + for key, scale_type_set in METER_DEVICE_CLASS_MAP.items(): + if scale_type in scale_type_set: + return key + + if value.command_class == CommandClass.SENSOR_MULTILEVEL: + sensor_type = get_multilevel_sensor_type(value) + if sensor_type == MultilevelSensorType.TARGET_TEMPERATURE: + return ENTITY_DESC_KEY_TARGET_TEMPERATURE + for ( + key, + sensor_type_set, + ) in MULTILEVEL_SENSOR_DEVICE_CLASS_MAP.items(): + if sensor_type in sensor_type_set: + return key + + return None diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 593d5ea4151..4744c7f9fc1 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -1,26 +1,24 @@ """Helper functions for Z-Wave JS integration.""" from __future__ import annotations -from typing import Any, cast +from typing import Any, Callable, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import ConfigurationValueType from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import Value as ZwaveValue, get_value_id +from zwave_js_server.model.value import ( + ConfigurationValue, + Value as ZwaveValue, + get_value_id, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import __version__ as HA_VERSION +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_TYPE, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import ( - DeviceRegistry, - async_get as async_get_dev_reg, -) -from homeassistant.helpers.entity_registry import ( - EntityRegistry, - async_get as async_get_ent_reg, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import ( @@ -79,7 +77,7 @@ def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str] @callback def async_get_node_from_device_id( - hass: HomeAssistant, device_id: str, dev_reg: DeviceRegistry | None = None + hass: HomeAssistant, device_id: str, dev_reg: dr.DeviceRegistry | None = None ) -> ZwaveNode: """ Get node from a device ID. @@ -87,7 +85,7 @@ def async_get_node_from_device_id( Raises ValueError if device is invalid or node can't be found. """ if not dev_reg: - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device_entry = dev_reg.async_get(device_id) if not device_entry: @@ -138,8 +136,8 @@ def async_get_node_from_device_id( def async_get_node_from_entity_id( hass: HomeAssistant, entity_id: str, - ent_reg: EntityRegistry | None = None, - dev_reg: DeviceRegistry | None = None, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, ) -> ZwaveNode: """ Get node from an entity ID. @@ -147,7 +145,7 @@ def async_get_node_from_entity_id( Raises ValueError if entity is invalid. """ if not ent_reg: - ent_reg = async_get_ent_reg(hass) + ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(entity_id) if entity_entry is None or entity_entry.platform != DOMAIN: @@ -159,6 +157,46 @@ def async_get_node_from_entity_id( return async_get_node_from_device_id(hass, entity_entry.device_id, dev_reg) +@callback +def async_get_nodes_from_area_id( + hass: HomeAssistant, + area_id: str, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, +) -> set[ZwaveNode]: + """Get nodes for all Z-Wave JS devices and entities that are in an area.""" + nodes: set[ZwaveNode] = set() + if ent_reg is None: + ent_reg = er.async_get(hass) + if dev_reg is None: + dev_reg = dr.async_get(hass) + # Add devices for all entities in an area that are Z-Wave JS entities + nodes.update( + { + async_get_node_from_device_id(hass, entity.device_id, dev_reg) + for entity in er.async_entries_for_area(ent_reg, area_id) + if entity.platform == DOMAIN and entity.device_id is not None + } + ) + # Add devices in an area that are Z-Wave JS devices + for device in dr.async_entries_for_area(dev_reg, area_id): + if next( + ( + config_entry_id + for config_entry_id in device.config_entries + if cast( + ConfigEntry, + hass.config_entries.async_get_entry(config_entry_id), + ).domain + == DOMAIN + ), + None, + ): + nodes.add(async_get_node_from_device_id(hass, device.id, dev_reg)) + + return nodes + + def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveValue: """Get a Z-Wave JS Value from a config.""" endpoint = None @@ -183,14 +221,14 @@ def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveVal def async_get_node_status_sensor_entity_id( hass: HomeAssistant, device_id: str, - ent_reg: EntityRegistry | None = None, - dev_reg: DeviceRegistry | None = None, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, ) -> str: """Get the node status sensor entity ID for a given Z-Wave JS device.""" if not ent_reg: - ent_reg = async_get_ent_reg(hass) + ent_reg = er.async_get(hass) if not dev_reg: - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device = dev_reg.async_get(device_id) if not device: raise HomeAssistantError("Invalid Device ID provided") @@ -209,3 +247,69 @@ def async_get_node_status_sensor_entity_id( ) return entity_id + + +def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: + """Remove keys from config where the value is an empty string or None.""" + return {key: value for key, value in config.items() if value not in ("", None)} + + +def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: + """Check type specific schema against config.""" + + def _check_type_schema(config: ConfigType) -> ConfigType: + """Check type specific schema against config.""" + return cast(ConfigType, schema_map[str(config[CONF_TYPE])](config)) + + return _check_type_schema + + +def copy_available_params( + input_dict: dict[str, Any], output_dict: dict[str, Any], params: list[str] +) -> None: + """Copy available params from input into output.""" + output_dict.update( + {param: input_dict[param] for param in params if param in input_dict} + ) + + +@callback +def async_is_device_config_entry_not_loaded( + hass: HomeAssistant, device_id: str +) -> bool: + """Return whether device's config entries are not loaded.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(device_id) + assert device + return any( + (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.state != ConfigEntryState.LOADED + for entry_id in device.config_entries + ) + + +def get_value_state_schema( + value: ZwaveValue, +) -> vol.Schema | None: + """Return device automation schema for a config entry.""" + if isinstance(value, ConfigurationValue): + min_ = value.metadata.min + max_ = value.metadata.max + if value.configuration_value_type in ( + ConfigurationValueType.RANGE, + ConfigurationValueType.MANUAL_ENTRY, + ): + return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_)) + + if value.configuration_value_type == ConfigurationValueType.ENUMERATED: + return vol.In({int(k): v for k, v in value.metadata.states.items()}) + + return None + + if value.metadata.states: + return vol.In({int(k): v for k, v in value.metadata.states.items()}) + + return vol.All( + vol.Coerce(int), + vol.Range(min=value.metadata.min, max=value.metadata.max), + ) diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 4f1de6c686d..0857b43e4ee 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -5,7 +5,8 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ColorComponent, CommandClass +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.color_switch import ColorComponent from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -287,39 +288,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): else: zwave_transition = {TRANSITION_DURATION: "default"} - if combined_color_val and isinstance(combined_color_val.value, dict): - colors_dict = {} - for color, value in colors.items(): - color_name = MULTI_COLOR_MAP[color] - colors_dict[color_name] = value - # set updated color object - await self.info.node.async_set_value( - combined_color_val, colors_dict, zwave_transition - ) - return - - # fallback to setting the color(s) one by one if multicolor fails - # not sure this is needed at all, but just in case + colors_dict = {} for color, value in colors.items(): - await self._async_set_color(color, value) - - async def _async_set_color( - self, - color: ColorComponent, - new_value: int, - transition: dict[str, str] | None = None, - ) -> None: - """Set defined color to given value.""" - # actually set the new color value - target_zwave_value = self.get_zwave_value( - "targetColor", - CommandClass.SWITCH_COLOR, - value_property_key=color.value, + color_name = MULTI_COLOR_MAP[color] + colors_dict[color_name] = value + # set updated color object + await self.info.node.async_set_value( + combined_color_val, colors_dict, zwave_transition ) - if target_zwave_value is None: - # guard for unsupported color - return - await self.info.node.async_set_value(target_zwave_value, new_value, transition) async def _async_set_brightness( self, brightness: int | None, transition: float | None = None diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index ad4a736d63e..0f2a0862d7f 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -6,12 +6,12 @@ from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ( +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ( ATTR_CODE_SLOT, ATTR_USERCODE, LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP, LOCK_CMD_CLASS_TO_PROPERTY_MAP, - CommandClass, DoorLockMode, ) from zwave_js_server.model.value import Value as ZwaveValue @@ -103,9 +103,7 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): ] ) == int(self.info.primary_value.value) - async def _set_lock_state( - self, target_state: str, **kwargs: dict[str, Any] - ) -> None: + async def _set_lock_state(self, target_state: str, **kwargs: Any) -> None: """Set the lock state.""" target_value: ZwaveValue = self.get_zwave_value( LOCK_CMD_CLASS_TO_PROPERTY_MAP[self.info.primary_value.command_class] @@ -116,11 +114,11 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): STATE_TO_ZWAVE_MAP[self.info.primary_value.command_class][target_state], ) - async def async_lock(self, **kwargs: dict[str, Any]) -> None: + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self._set_lock_state(STATE_LOCKED) - async def async_unlock(self, **kwargs: dict[str, Any]) -> None: + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" await self._set_lock_state(STATE_UNLOCKED) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index b24bc957303..7953e33d6e3 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,8 +3,13 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.28.0"], + "requirements": ["zwave-js-server-python==0.29.1"], "codeowners": ["@home-assistant/z-wave"], - "dependencies": ["http", "websocket_api"], - "iot_class": "local_push" + "dependencies": ["usb", "http", "websocket_api"], + "iot_class": "local_push", + "usb": [ + {"vid":"0658","pid":"0200","known_devices":["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"]}, + {"vid":"10C4","pid":"8A2A","known_devices":["Nortek HUSBZB-1"]}, + {"vid":"10C4","pid":"EA60","known_devices":["Aeotec Z-Stick 7", "Silicon Labs UZB-7", "Zooz ZST10 700"]} + ] } diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index e53e5942999..675a396fb7b 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -26,7 +26,10 @@ async def async_setup_entry( def async_add_number(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave number entity.""" entities: list[ZWaveBaseEntity] = [] - entities.append(ZwaveNumberEntity(config_entry, client, info)) + if info.platform_hint == "volume": + entities.append(ZwaveVolumeNumberEntity(config_entry, client, info)) + else: + entities.append(ZwaveNumberEntity(config_entry, client, info)) async_add_entities(entities) config_entry.async_on_unload( @@ -87,3 +90,38 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): async def async_set_value(self, value: float) -> None: """Set new value.""" await self.info.node.async_set_value(self._target_value, value) + + +class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): + """Representation of a volume number entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveVolumeNumberEntity entity.""" + super().__init__(config_entry, client, info) + self.correction_factor = int( + self.info.primary_value.metadata.max - self.info.primary_value.metadata.min + ) + # Fallback in case we can't properly calculate correction factor + if self.correction_factor == 0: + self.correction_factor = 1 + + # Entity class attributes + self._attr_min_value = 0 + self._attr_max_value = 1 + self._attr_step = 0.01 + self._attr_name = self.generate_name(include_value_name=True) + + @property + def value(self) -> float | None: + """Return the entity value.""" + if self.info.primary_value.value is None: + return None + return float(self.info.primary_value.value) / self.correction_factor + + async def async_set_value(self, value: float) -> None: + """Set new value.""" + await self.info.node.async_set_value( + self.info.primary_value, round(value * self.correction_factor) + ) diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py new file mode 100644 index 00000000000..fae87fd24de --- /dev/null +++ b/homeassistant/components/zwave_js/select.py @@ -0,0 +1,128 @@ +"""Support for Z-Wave controls using the select platform.""" +from __future__ import annotations + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.sound_switch import ToneID + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_CLIENT, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Z-Wave Select entity from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_select(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave select entity.""" + entities: list[ZWaveBaseEntity] = [] + if info.platform_hint == "Default tone": + entities.append(ZwaveDefaultToneSelectEntity(config_entry, client, info)) + else: + entities.append(ZwaveSelectEntity(config_entry, client, info)) + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{SELECT_DOMAIN}", + async_add_select, + ) + ) + + +class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): + """Representation of a Z-Wave select entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveSelectEntity entity.""" + super().__init__(config_entry, client, info) + + # Entity class attributes + self._attr_name = self.generate_name(include_value_name=True) + self._attr_options = list(self.info.primary_value.metadata.states.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.info.primary_value.metadata.states.get( + str(self.info.primary_value.value), self.info.primary_value.value + ) + ) + + async def async_select_option(self, option: str | int) -> None: + """Change the selected option.""" + key = next( + key + for key, val in self.info.primary_value.metadata.states.items() + if val == option + ) + await self.info.node.async_set_value(self.info.primary_value, int(key)) + + +class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): + """Representation of a Z-Wave default tone select entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveDefaultToneSelectEntity entity.""" + super().__init__(config_entry, client, info) + self._tones_value = self.get_zwave_value( + "toneId", command_class=CommandClass.SOUND_SWITCH + ) + + # Entity class attributes + self._attr_name = self.generate_name( + include_value_name=True, alternate_value_name=info.platform_hint + ) + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + return [ + val + for key, val in self._tones_value.metadata.states.items() + if int(key) not in (ToneID.DEFAULT, ToneID.OFF) + ] + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + return str( + self._tones_value.metadata.states.get( + str(self.info.primary_value.value), self.info.primary_value.value + ) + ) + + async def async_select_option(self, option: str | int) -> None: + """Change the selected option.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + key = next( + key + for key, val in self._tones_value.metadata.states.items() + if val == option + ) + await self.info.node.async_set_value(self.info.primary_value, int(key)) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 7b491661e68..09d44f7f24a 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -1,45 +1,79 @@ """Representation of Z-Wave sensors.""" from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass import logging from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const.command_class.meter import ( + RESET_METER_OPTION_TARGET_VALUE, + RESET_METER_OPTION_TYPE, +) from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue +from zwave_js_server.util.command_class import get_meter_type from homeassistant.components.sensor import ( - ATTR_LAST_RESET, - DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, DEVICE_CLASS_CURRENT, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt -from .const import ATTR_METER_TYPE, ATTR_VALUE, DATA_CLIENT, DOMAIN, SERVICE_RESET_METER +from .const import ( + ATTR_METER_TYPE, + ATTR_METER_TYPE_NAME, + ATTR_VALUE, + DATA_CLIENT, + DOMAIN, + ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_CO, + ENTITY_DESC_KEY_CO2, + ENTITY_DESC_KEY_CURRENT, + ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + ENTITY_DESC_KEY_HUMIDITY, + ENTITY_DESC_KEY_ILLUMINANCE, + ENTITY_DESC_KEY_MEASUREMENT, + ENTITY_DESC_KEY_POWER, + ENTITY_DESC_KEY_POWER_FACTOR, + ENTITY_DESC_KEY_PRESSURE, + ENTITY_DESC_KEY_SIGNAL_STRENGTH, + ENTITY_DESC_KEY_TARGET_TEMPERATURE, + ENTITY_DESC_KEY_TEMPERATURE, + ENTITY_DESC_KEY_TIMESTAMP, + ENTITY_DESC_KEY_TOTAL_INCREASING, + ENTITY_DESC_KEY_VOLTAGE, + SERVICE_RESET_METER, +) from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity from .helpers import get_device_id @@ -47,6 +81,107 @@ from .helpers import get_device_id LOGGER = logging.getLogger(__name__) +@dataclass +class ZwaveSensorEntityDescription(SensorEntityDescription): + """Base description of a Zwave Sensor entity.""" + + info: ZwaveDiscoveryInfo | None = None + + +ENTITY_DESCRIPTION_KEY_MAP: dict[str, ZwaveSensorEntityDescription] = { + ENTITY_DESC_KEY_BATTERY: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_BATTERY, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_CURRENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CURRENT, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_VOLTAGE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_VOLTAGE, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_ENERGY_MEASUREMENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + ENTITY_DESC_KEY_POWER: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_POWER, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_POWER_FACTOR: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_POWER_FACTOR, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_CO: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CO, + device_class=DEVICE_CLASS_CO, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_CO2: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_CO2, + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_HUMIDITY: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_ILLUMINANCE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_ILLUMINANCE, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_PRESSURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_PRESSURE, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_SIGNAL_STRENGTH: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_SIGNAL_STRENGTH, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TEMPERATURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TIMESTAMP: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TIMESTAMP, + device_class=DEVICE_CLASS_TIMESTAMP, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TARGET_TEMPERATURE: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TARGET_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=None, + ), + ENTITY_DESC_KEY_MEASUREMENT: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_MEASUREMENT, + device_class=None, + state_class=STATE_CLASS_MEASUREMENT, + ), + ENTITY_DESC_KEY_TOTAL_INCREASING: ZwaveSensorEntityDescription( + ENTITY_DESC_KEY_TOTAL_INCREASING, + device_class=None, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -60,16 +195,25 @@ async def async_setup_entry( """Add Z-Wave Sensor.""" entities: list[ZWaveBaseEntity] = [] + entity_description = ENTITY_DESCRIPTION_KEY_MAP.get( + info.platform_data + ) or ZwaveSensorEntityDescription("base_sensor") + entity_description.info = info + if info.platform_hint == "string_sensor": - entities.append(ZWaveStringSensor(config_entry, client, info)) + entities.append(ZWaveStringSensor(config_entry, client, entity_description)) elif info.platform_hint == "numeric_sensor": - entities.append(ZWaveNumericSensor(config_entry, client, info)) + entities.append( + ZWaveNumericSensor(config_entry, client, entity_description) + ) elif info.platform_hint == "list_sensor": - entities.append(ZWaveListSensor(config_entry, client, info)) + entities.append(ZWaveListSensor(config_entry, client, entity_description)) elif info.platform_hint == "config_parameter": - entities.append(ZWaveConfigParameterSensor(config_entry, client, info)) + entities.append( + ZWaveConfigParameterSensor(config_entry, client, entity_description) + ) elif info.platform_hint == "meter": - entities.append(ZWaveMeterSensor(config_entry, client, info)) + entities.append(ZWaveMeterSensor(config_entry, client, entity_description)) else: LOGGER.warning( "Sensor not implemented for %s/%s", @@ -119,76 +263,30 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): self, config_entry: ConfigEntry, client: ZwaveClient, - info: ZwaveDiscoveryInfo, + entity_description: ZwaveSensorEntityDescription, ) -> None: """Initialize a ZWaveSensorBase entity.""" - super().__init__(config_entry, client, info) + assert entity_description.info + super().__init__(config_entry, client, entity_description.info) + self.entity_description = entity_description # Entity class attributes + self._attr_force_update = True self._attr_name = self.generate_name(include_value_name=True) - self._attr_device_class = self._get_device_class() - self._attr_state_class = self._get_state_class() - - def _get_device_class(self) -> str | None: - """ - Get the device class of the sensor. - - This should be run once during initialization so we don't have to calculate - this value on every state update. - """ - if self.info.primary_value.command_class == CommandClass.BATTERY: - return DEVICE_CLASS_BATTERY - if isinstance(self.info.primary_value.property_, str): - property_lower = self.info.primary_value.property_.lower() - if "humidity" in property_lower: - return DEVICE_CLASS_HUMIDITY - if "temperature" in property_lower: - return DEVICE_CLASS_TEMPERATURE - if self.info.primary_value.metadata.unit == "A": - return DEVICE_CLASS_CURRENT - if self.info.primary_value.metadata.unit == "W": - return DEVICE_CLASS_POWER - if self.info.primary_value.metadata.unit == "kWh": - return DEVICE_CLASS_ENERGY - if self.info.primary_value.metadata.unit == "V": - return DEVICE_CLASS_VOLTAGE - if self.info.primary_value.metadata.unit == "Lux": - return DEVICE_CLASS_ILLUMINANCE - return None - - def _get_state_class(self) -> str | None: - """ - Get the state class of the sensor. - - This should be run once during initialization so we don't have to calculate - this value on every state update. - """ - if self.info.primary_value.command_class == CommandClass.BATTERY: - return STATE_CLASS_MEASUREMENT - if isinstance(self.info.primary_value.property_, str): - property_lower = self.info.primary_value.property_.lower() - if "humidity" in property_lower or "temperature" in property_lower: - return STATE_CLASS_MEASUREMENT - return None - - @property - def force_update(self) -> bool: - """Force updates.""" - return True class ZWaveStringSensor(ZwaveSensorBase): """Representation of a Z-Wave String sensor.""" @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None return str(self.info.primary_value.value) @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of measurement the value is expressed in.""" if self.info.primary_value.metadata.unit is None: return None @@ -198,31 +296,15 @@ class ZWaveStringSensor(ZwaveSensorBase): class ZWaveNumericSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor.""" - def __init__( - self, - config_entry: ConfigEntry, - client: ZwaveClient, - info: ZwaveDiscoveryInfo, - ) -> None: - """Initialize a ZWaveNumericSensor entity.""" - super().__init__(config_entry, client, info) - - # Entity class attributes - if self.info.primary_value.command_class == CommandClass.BASIC: - self._attr_name = self.generate_name( - include_value_name=True, - alternate_value_name=self.info.primary_value.command_class_name, - ) - @property - def state(self) -> float: + def native_value(self) -> float: """Return state of the sensor.""" if self.info.primary_value.value is None: return 0 return round(float(self.info.primary_value.value), 2) @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return unit of measurement the value is expressed in.""" if self.info.primary_value.metadata.unit is None: return None @@ -234,64 +316,19 @@ class ZWaveNumericSensor(ZwaveSensorBase): return str(self.info.primary_value.metadata.unit) -class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): +class ZWaveMeterSensor(ZWaveNumericSensor): """Representation of a Z-Wave Meter CC sensor.""" - def __init__( - self, - config_entry: ConfigEntry, - client: ZwaveClient, - info: ZwaveDiscoveryInfo, - ) -> None: - """Initialize a ZWaveNumericSensor entity.""" - super().__init__(config_entry, client, info) - - # Entity class attributes - self._attr_state_class = STATE_CLASS_MEASUREMENT - if self.device_class == DEVICE_CLASS_ENERGY: - self._attr_last_reset = dt.utc_from_timestamp(0) - - @callback - def async_update_last_reset( - self, node: ZwaveNode, endpoint: int, meter_type: int | None - ) -> None: - """Update last reset.""" - # If the signal is not for this node or is for a different endpoint, - # or a meter type was specified and doesn't match this entity's meter type: - if ( - self.info.node != node - or self.info.primary_value.endpoint != endpoint - or meter_type is not None - and self.info.primary_value.metadata.cc_specific.get("meterType") - != meter_type - ): - return - - self._attr_last_reset = dt.utcnow() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Call when entity is added.""" - await super().async_added_to_hass() - - # If the meter is not an accumulating meter type, do not reset. - if self.device_class != DEVICE_CLASS_ENERGY: - return - - # Restore the last reset time from stored state - restored_state = await self.async_get_last_state() - if restored_state and ATTR_LAST_RESET in restored_state.attributes: - self._attr_last_reset = dt.parse_datetime( - restored_state.attributes[ATTR_LAST_RESET] - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{SERVICE_RESET_METER}", - self.async_update_last_reset, - ) - ) + @property + def extra_state_attributes(self) -> Mapping[str, int | str] | None: + """Return extra state attributes.""" + meter_type = get_meter_type(self.info.primary_value) + if meter_type: + return { + ATTR_METER_TYPE: meter_type.value, + ATTR_METER_TYPE_NAME: meter_type.name, + } + return None async def async_reset_meter( self, meter_type: int | None = None, value: int | None = None @@ -301,9 +338,9 @@ class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): primary_value = self.info.primary_value options = {} if meter_type is not None: - options["type"] = meter_type + options[RESET_METER_OPTION_TYPE] = meter_type if value is not None: - options["targetValue"] = value + options[RESET_METER_OPTION_TARGET_VALUE] = value args = [options] if options else [] await node.endpoints[primary_value.endpoint].async_invoke_cc_api( CommandClass.METER, "reset", *args, wait_for_result=False @@ -315,15 +352,6 @@ class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): options, ) - # Notify meters that may have been reset - async_dispatcher_send( - self.hass, - f"{DOMAIN}_{SERVICE_RESET_METER}", - node, - primary_value.endpoint, - options.get("type"), - ) - class ZWaveListSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor with multiple states.""" @@ -332,10 +360,10 @@ class ZWaveListSensor(ZwaveSensorBase): self, config_entry: ConfigEntry, client: ZwaveClient, - info: ZwaveDiscoveryInfo, + entity_description: ZwaveSensorEntityDescription, ) -> None: """Initialize a ZWaveListSensor entity.""" - super().__init__(config_entry, client, info) + super().__init__(config_entry, client, entity_description) # Entity class attributes self._attr_name = self.generate_name( @@ -345,7 +373,7 @@ class ZWaveListSensor(ZwaveSensorBase): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None @@ -362,7 +390,7 @@ class ZWaveListSensor(ZwaveSensorBase): def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" # add the value's int value as property for multi-value (list) items - return {"value": self.info.primary_value.value} + return {ATTR_VALUE: self.info.primary_value.value} class ZWaveConfigParameterSensor(ZwaveSensorBase): @@ -372,10 +400,10 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): self, config_entry: ConfigEntry, client: ZwaveClient, - info: ZwaveDiscoveryInfo, + entity_description: ZwaveSensorEntityDescription, ) -> None: """Initialize a ZWaveConfigParameterSensor entity.""" - super().__init__(config_entry, client, info) + super().__init__(config_entry, client, entity_description) self._primary_value = cast(ConfigurationValue, self.info.primary_value) # Entity class attributes @@ -387,7 +415,7 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): ) @property - def state(self) -> str | None: + def native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None @@ -409,7 +437,7 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): if self._primary_value.configuration_value_type == ConfigurationValueType.RANGE: return None # add the value's int value as property for multi-value (list) items - return {"value": self.info.primary_value.value} + return {ATTR_VALUE: self.info.primary_value.value} class ZWaveNodeStatusSensor(SensorEntity): @@ -439,15 +467,16 @@ class ZWaveNodeStatusSensor(SensorEntity): self._attr_device_info = { "identifiers": {get_device_id(self.client, self.node)}, } - self._attr_state: str = node.status.name.lower() + self._attr_native_value: str = node.status.name.lower() async def async_poll_value(self, _: bool) -> None: """Poll a value.""" raise ValueError("There is no value to poll for this entity") + @callback def _status_changed(self, _: dict) -> None: """Call when status event is received.""" - self._attr_state = self.node.status.name.lower() + self._attr_native_value = self.node.status.name.lower() self.async_write_ha_state() async def async_added_to_hass(self) -> None: @@ -463,8 +492,3 @@ class ZWaveNodeStatusSensor(SensorEntity): ) ) self.async_write_ha_state() - - @property - def available(self) -> bool: - """Return entity availability.""" - return self.client.connected and bool(self.node.ready) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index fa0e93a72aa..431f88a875d 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -17,15 +17,19 @@ from zwave_js_server.util.node import ( async_set_config_parameter, ) -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.components.group import expand_entity_ids +from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity_registry import EntityRegistry from . import const -from .helpers import async_get_node_from_device_id, async_get_node_from_entity_id +from .helpers import ( + async_get_node_from_device_id, + async_get_node_from_entity_id, + async_get_nodes_from_area_id, +) _LOGGER = logging.getLogger(__name__) @@ -80,7 +84,10 @@ class ZWaveServices: """Class that holds our services (Zwave Commands) that should be published to hass.""" def __init__( - self, hass: HomeAssistant, ent_reg: EntityRegistry, dev_reg: DeviceRegistry + self, + hass: HomeAssistant, + ent_reg: er.EntityRegistry, + dev_reg: dr.DeviceRegistry, ) -> None: """Initialize with hass object.""" self._hass = hass @@ -95,7 +102,8 @@ class ZWaveServices: def get_nodes_from_service_data(val: dict[str, Any]) -> dict[str, Any]: """Get nodes set from service data.""" nodes: set[ZwaveNode] = set() - for entity_id in val.pop(ATTR_ENTITY_ID, []): + # Convert all entity IDs to nodes + for entity_id in expand_entity_ids(self._hass, val.pop(ATTR_ENTITY_ID, [])): try: nodes.add( async_get_node_from_entity_id( @@ -104,6 +112,16 @@ class ZWaveServices: ) except ValueError as err: const.LOGGER.warning(err.args[0]) + + # Convert all area IDs to nodes + for area_id in val.pop(ATTR_AREA_ID, []): + nodes.update( + async_get_nodes_from_area_id( + self._hass, area_id, self._ent_reg, self._dev_reg + ) + ) + + # Convert all device IDs to nodes for device_id in val.pop(ATTR_DEVICE_ID, []): try: nodes.add( @@ -152,6 +170,7 @@ class ZWaveServices: @callback def validate_entities(val: dict[str, Any]) -> dict[str, Any]: """Validate entities exist and are from the zwave_js platform.""" + val[ATTR_ENTITY_ID] = expand_entity_ids(self._hass, val[ATTR_ENTITY_ID]) for entity_id in val[ATTR_ENTITY_ID]: entry = self._ent_reg.async_get(entity_id) if entry is None or entry.platform != const.DOMAIN: @@ -168,6 +187,9 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), @@ -182,7 +204,9 @@ class ZWaveServices: vol.Coerce(int), BITMASK_SCHEMA, cv.string ), }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), parameter_name_does_not_need_bitmask, get_nodes_from_service_data, ), @@ -196,6 +220,9 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), @@ -210,7 +237,9 @@ class ZWaveServices: }, ), }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), @@ -240,6 +269,9 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), @@ -256,7 +288,9 @@ class ZWaveServices: vol.Optional(const.ATTR_WAIT_FOR_RESULT): cv.boolean, vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), @@ -269,6 +303,9 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), @@ -286,7 +323,9 @@ class ZWaveServices: vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, }, vol.Any( - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), broadcast_command, ), get_nodes_from_service_data, @@ -302,12 +341,17 @@ class ZWaveServices: schema=vol.Schema( vol.All( { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(ATTR_DEVICE_ID): vol.All( cv.ensure_list, [cv.string] ), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index de74f55fa9a..4ef89b9f4cd 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ToneID +from zwave_js_server.const.command_class.sound_switch import ToneID from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN, SirenEntity from homeassistant.components.siren.const import ( @@ -58,9 +58,9 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): """Initialize a ZwaveSirenEntity entity.""" super().__init__(config_entry, client, info) # Entity class attributes - self._attr_available_tones = list( - self.info.primary_value.metadata.states.values() - ) + self._attr_available_tones = { + int(id): val for id, val in self.info.primary_value.metadata.states.items() + } self._attr_supported_features = ( SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET ) @@ -82,23 +82,15 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - tone: str | None = kwargs.get(ATTR_TONE) + tone_id: int | None = kwargs.get(ATTR_TONE) options = {} if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: options["volume"] = round(volume * 100) # Play the default tone if a tone isn't provided - if tone is None: + if tone_id is None: await self.async_set_value(ToneID.DEFAULT, options) return - tone_id = int( - next( - key - for key, value in self.info.primary_value.metadata.states.items() - if value == tone - ) - ) - await self.async_set_value(tone_id, options) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 628451a6215..d0bdec1a80c 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,11 +1,15 @@ { "config": { + "flow_title": "{name}", "step": { "manual": { "data": { "url": "[%key:common::config_flow::data::url%]" } }, + "usb_confirm": { + "description": "Do you want to setup {name} with the Z-Wave JS add-on?" + }, "on_supervisor": { "title": "Select connection method", "description": "Do you want to use the Z-Wave JS Supervisor add-on?", @@ -44,7 +48,9 @@ "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", "addon_start_failed": "Failed to start the Z-Wave JS add-on.", "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "discovery_requires_supervisor": "Discovery requires the supervisor.", + "not_zwave_device": "Discovered device is not a Z-Wave device." }, "progress": { "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", @@ -104,6 +110,8 @@ "event.value_notification.basic": "Basic CC event on {subtype}", "event.value_notification.central_scene": "Central Scene action on {subtype}", "event.value_notification.scene_activation": "Scene Activation on {subtype}", + "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", + "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value", "state.node_status": "Node status changed" }, "condition_type": { diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 0bc6b8d5349..bd86a3b8377 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -5,7 +5,9 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import BarrierEventSignalingSubsystemState +from zwave_js_server.const.command_class.barrior_operator import ( + BarrierEventSignalingSubsystemState, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index a758d81e553..ac18c44b489 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -8,7 +8,9 @@ "addon_start_failed": "No s'ha pogut iniciar el complement Z-Wave JS.", "already_configured": "El dispositiu ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", - "cannot_connect": "Ha fallat la connexi\u00f3" + "cannot_connect": "Ha fallat la connexi\u00f3", + "discovery_requires_supervisor": "El descobriment requereix el supervisor.", + "not_zwave_device": "El dispositiu descobert no \u00e9s un dispositiu Z-Wave." }, "error": { "addon_start_failed": "No s'ha pogut iniciar el complement Z-Wave JS. Comprova la configuraci\u00f3.", @@ -16,6 +18,7 @@ "invalid_ws_url": "URL del websocket inv\u00e0lid", "unknown": "Error inesperat" }, + "flow_title": "{name}", "progress": { "install_addon": "Espera mentre finalitza la instal\u00b7laci\u00f3 del complement Z-Wave JS. Pot tardar uns quants minuts.", "start_addon": "Espera mentre es completa la inicialitzaci\u00f3 del complement Z-Wave JS. Pot tardar uns segons." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "El complement Z-Wave JS s'est\u00e0 iniciant." + }, + "usb_confirm": { + "description": "Vols configurar {name} amb el complement Z-Wave JS?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Esdeveniment CC b\u00e0sic a {subtype}", "event.value_notification.central_scene": "Acci\u00f3 d'escena central a {subtype}", "event.value_notification.scene_activation": "Activaci\u00f3 d'escena a {subtype}", - "state.node_status": "L'estat del node ha canviat" + "state.node_status": "L'estat del node ha canviat", + "zwave_js.value_updated.config_parameter": "Canvi del valor del par\u00e0metre de configuraci\u00f3 {subtype}", + "zwave_js.value_updated.value": "Canvi del valor en un valor Z-Wave JS" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/cs.json b/homeassistant/components/zwave_js/translations/cs.json index 9f8af44c451..05efdb8e5ff 100644 --- a/homeassistant/components/zwave_js/translations/cs.json +++ b/homeassistant/components/zwave_js/translations/cs.json @@ -21,5 +21,20 @@ } } } + }, + "device_automation": { + "condition_type": { + "config_parameter": "Hodnota konfigura\u010dn\u00edho parametru {subtype}", + "node_status": "Stav uzlu", + "value": "Aktu\u00e1ln\u00ed hodnota Z-Wave hodnoty" + }, + "trigger_type": { + "event.notification.entry_control": "Odeslat ozn\u00e1men\u00ed o \u0159\u00edzen\u00ed vstupu", + "event.notification.notification": "Odeslal ozn\u00e1men\u00ed", + "event.value_notification.basic": "Z\u00e1kladn\u00ed ud\u00e1lost CC na {subtype}", + "event.value_notification.central_scene": "Akce centr\u00e1ln\u00ed sc\u00e9ny na {subtype}", + "event.value_notification.scene_activation": "Aktivace sc\u00e9ny na {subtype}", + "state.node_status": "Stav uzlu zm\u011bn\u011bn" + } } } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index 9b01865d3be..8d9634c3f46 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -8,7 +8,9 @@ "addon_start_failed": "Starten des Z-Wave JS Add-ons fehlgeschlagen.", "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", - "cannot_connect": "Verbindung fehlgeschlagen" + "cannot_connect": "Verbindung fehlgeschlagen", + "discovery_requires_supervisor": "Discovery erfordert den Supervisor.", + "not_zwave_device": "Das erkannte Ger\u00e4t ist kein Z-Wave-Ger\u00e4t." }, "error": { "addon_start_failed": "Fehler beim Starten des Z-Wave JS Add-Ons. \u00dcberpr\u00fcfe die Konfiguration.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Ung\u00fcltige Websocket-URL", "unknown": "Unerwarteter Fehler" }, + "flow_title": "{name}", "progress": { "install_addon": "Bitte warte, w\u00e4hrend die Installation des Z-Wave JS Add-ons abgeschlossen wird. Dies kann einige Minuten dauern.", "start_addon": "Bitte warte, w\u00e4hrend der Start des Z-Wave JS Add-ons abgeschlossen wird. Dies kann einige Sekunden dauern." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Z-Wave JS Add-on wird gestartet." + }, + "usb_confirm": { + "description": "M\u00f6chtest du {name} mit dem Z-Wave JS Add-on einrichten?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Grundlegendes CC-Ereignis auf {subtype}", "event.value_notification.central_scene": "Zentrale Szenenaktion auf {subtype}", "event.value_notification.scene_activation": "Szenenaktivierung auf {subtype}", - "state.node_status": "Knotenstatus ge\u00e4ndert" + "state.node_status": "Knotenstatus ge\u00e4ndert", + "zwave_js.value_updated.config_parameter": "Wert\u00e4nderung des Konfigurationsparameters {subtype}", + "zwave_js.value_updated.value": "Wert\u00e4nderung bei einem Z-Wave JS Wert" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index b742a011d19..8ba33702d1d 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -8,7 +8,9 @@ "addon_start_failed": "Failed to start the Z-Wave JS add-on.", "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "discovery_requires_supervisor": "Discovery requires the supervisor.", + "not_zwave_device": "Discovered device is not a Z-Wave device." }, "error": { "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Invalid websocket URL", "unknown": "Unexpected error" }, + "flow_title": "{name}", "progress": { "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "The Z-Wave JS add-on is starting." + }, + "usb_confirm": { + "description": "Do you want to setup {name} with the Z-Wave JS add-on?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Basic CC event on {subtype}", "event.value_notification.central_scene": "Central Scene action on {subtype}", "event.value_notification.scene_activation": "Scene Activation on {subtype}", - "state.node_status": "Node status changed" + "state.node_status": "Node status changed", + "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", + "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index 522c145d6d5..efed557fe73 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -8,7 +8,9 @@ "addon_start_failed": "Z-Wave JS-i lisandmooduli k\u00e4ivitamine nurjus.", "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "already_in_progress": "Seadistamine on juba k\u00e4imas", - "cannot_connect": "\u00dchendamine nurjus" + "cannot_connect": "\u00dchendamine nurjus", + "discovery_requires_supervisor": "Avastamine n\u00f5uab supervisorit.", + "not_zwave_device": "Avastatud seade ei ole Z-Wave seade." }, "error": { "addon_start_failed": "Z-Wave JS lisandmooduli k\u00e4ivitamine nurjus. Kontrolli seadistusi.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Vale sihtkoha aadress", "unknown": "Ootamatu t\u00f5rge" }, + "flow_title": "{name}", "progress": { "install_addon": "Palun oota kuni Z-Wave JS lisandmoodul on paigaldatud. See v\u00f5ib v\u00f5tta mitu minutit.", "start_addon": "Palun oota kuni Z-Wave JS lisandmooduli ak\u00e4ivitumine l\u00f5ppeb. See v\u00f5ib v\u00f5tta m\u00f5ned sekundid." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Z-Wave JS lisandmoodul k\u00e4ivitub." + }, + "usb_confirm": { + "description": "Kas seadistada Z-Wave JS lisandmoodul {name}?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "CC p\u00f5his\u00fcndmus {subtype}", "event.value_notification.central_scene": "Keskse stseeni tegevus {subtype}", "event.value_notification.scene_activation": "Stseeni aktiveerimine saidil {subtype}", - "state.node_status": "S\u00f5lme olek muutus" + "state.node_status": "S\u00f5lme olek muutus", + "zwave_js.value_updated.config_parameter": "Seadeparameetri {subtype} v\u00e4\u00e4rtuse muutmine", + "zwave_js.value_updated.value": "Z-Wave JS v\u00e4\u00e4rtuse muutus" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/he.json b/homeassistant/components/zwave_js/translations/he.json index 7c1cab98854..fae03188b81 100644 --- a/homeassistant/components/zwave_js/translations/he.json +++ b/homeassistant/components/zwave_js/translations/he.json @@ -9,6 +9,7 @@ "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": { "configure_addon": { "data": { diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json index 3696380b43a..23d185f1ded 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -8,7 +8,9 @@ "addon_start_failed": "Kan de Z-Wave JS add-on niet starten.", "already_configured": "Apparaat is al geconfigureerd", "already_in_progress": "De configuratiestroom is al aan de gang", - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "discovery_requires_supervisor": "Ontdekking vereist de Supervisor.", + "not_zwave_device": "Het ontdekte apparaat is niet een Z-Wave apparaat." }, "error": { "addon_start_failed": "Het is niet gelukt om de Z-Wave JS add-on te starten. Controleer de configuratie.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Ongeldige websocket URL", "unknown": "Onverwachte fout" }, + "flow_title": "{name}", "progress": { "install_addon": "Een ogenblik geduld terwijl de installatie van de Z-Wave JS add-on is voltooid. Dit kan enkele minuten duren.", "start_addon": "Wacht alstublieft terwijl de Z-Wave JS add-on start voltooid is. Dit kan enkele seconden duren." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "De add-on Z-Wave JS wordt gestart." + }, + "usb_confirm": { + "description": "Wilt u {naam} instellen met de Z-Wave JS add-on?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "Basis CC-evenement op {subtype}", "event.value_notification.central_scene": "Centrale Sc\u00e8ne actie op {subtype}", "event.value_notification.scene_activation": "Sc\u00e8ne-activering op {subtype}", - "state.node_status": "Knooppuntstatus gewijzigd" + "state.node_status": "Knooppuntstatus gewijzigd", + "zwave_js.value_updated.config_parameter": "Waardeverandering op configuratieparameter {subtype}", + "zwave_js.value_updated.value": "Waardeverandering op een Z-Wave JS-waarde" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index 8eb4c176356..b69b1cb4f7a 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -8,7 +8,9 @@ "addon_start_failed": "Kunne ikke starte Z-Wave JS-tillegget.", "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", - "cannot_connect": "Tilkobling mislyktes" + "cannot_connect": "Tilkobling mislyktes", + "discovery_requires_supervisor": "Oppdagelsen krever veilederen.", + "not_zwave_device": "Oppdaget enhet er ikke en Z-Wave-enhet." }, "error": { "addon_start_failed": "Kunne ikke starte Z-Wave JS-tillegg. Sjekk konfigurasjonen.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Ugyldig websocket URL", "unknown": "Uventet feil" }, + "flow_title": "{name}", "progress": { "install_addon": "Vent mens installasjonen av Z-Wave JS-tillegg er ferdig. Dette kan ta flere minutter.", "start_addon": "Vent mens Z-Wave JS-tillegget er ferdig startet. Dette kan ta noen sekunder." @@ -48,9 +51,29 @@ }, "start_addon": { "title": "Z-Wave JS-tillegget starter" + }, + "usb_confirm": { + "description": "Vil du konfigurere {name} med Z-Wave JS-tillegget?" } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Konfigurer parameter {subtype} verdi", + "node_status": "Nodestatus", + "value": "Gjeldende verdi for en Z-Wave-verdi" + }, + "trigger_type": { + "event.notification.entry_control": "Sendte et varsel om oppf\u00f8ringskontroll", + "event.notification.notification": "Sendte et varsel", + "event.value_notification.basic": "Grunnleggende CC -hendelse p\u00e5 {subtype}", + "event.value_notification.central_scene": "Sentral scenehandling p\u00e5 {subtype}", + "event.value_notification.scene_activation": "Sceneaktivering p\u00e5 {subtype}", + "state.node_status": "Nodestatus endret", + "zwave_js.value_updated.config_parameter": "Verdiendring p\u00e5 konfigurasjonsparameteren {subtype}", + "zwave_js.value_updated.value": "Verdiendring p\u00e5 en Z-Wave JS-verdi" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Kunne ikke hente oppdagelsesinformasjon om Z-Wave JS-tillegg", diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index cc000691d25..bd842cb1359 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -8,7 +8,9 @@ "addon_start_failed": "Nie uda\u0142o si\u0119 uruchomi\u0107 dodatku Z-Wave JS.", "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", "already_in_progress": "Konfiguracja jest ju\u017c w toku", - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "discovery_requires_supervisor": "Wykrywanie wymaga Supervisora.", + "not_zwave_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Z-Wave." }, "error": { "addon_start_failed": "Nie uda\u0142o si\u0119 uruchomi\u0107 dodatku Z-Wave JS. Sprawd\u017a konfiguracj\u0119", @@ -16,6 +18,7 @@ "invalid_ws_url": "Nieprawid\u0142owy URL websocket", "unknown": "Nieoczekiwany b\u0142\u0105d" }, + "flow_title": "{name}", "progress": { "install_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 instalacja dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 kilka minut.", "start_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 uruchamianie dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 chwil\u0119." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Dodatek Z-Wave JS uruchamia si\u0119..." + }, + "usb_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name} z dodatkiem Z-Wave JS?" } } }, @@ -63,7 +69,9 @@ "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" + "state.node_status": "Zmieni\u0142 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" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index 03529769828..994bfb54cfc 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -8,7 +8,9 @@ "addon_start_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS.", "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.", - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "discovery_requires_supervisor": "\u0414\u043b\u044f \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f Supervisor.", + "not_zwave_device": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Z-Wave." }, "error": { "addon_start_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e.", @@ -16,6 +18,7 @@ "invalid_ws_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, + "flow_title": "{name}", "progress": { "install_addon": "\u041f\u043e\u0434\u043e\u0436\u0434\u0438\u0442\u0435, \u043f\u043e\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0441\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430 \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043c\u0438\u043d\u0443\u0442.", "start_addon": "\u041f\u043e\u0434\u043e\u0436\u0434\u0438\u0442\u0435, \u043f\u043e\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0441\u044f \u0437\u0430\u043f\u0443\u0441\u043a \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043d\u044f\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0441\u0435\u043a\u0443\u043d\u0434." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "\u0414\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS \u0437\u0430\u043f\u0443\u0441\u043a\u0430\u0435\u0442\u0441\u044f" + }, + "usb_confirm": { + "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS?" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "\u0411\u0430\u0437\u043e\u0432\u043e\u0435 \u0441\u043e\u0431\u044b\u0442\u0438\u0435 CC \u043d\u0430 {subtype}", "event.value_notification.central_scene": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435 \"\u0426\u0435\u043d\u0442\u0440\u0430\u043b\u044c\u043d\u0430\u044f \u0441\u0446\u0435\u043d\u0430\" \u043d\u0430 {subtype}", "event.value_notification.scene_activation": "\u0410\u043a\u0442\u0438\u0432\u0430\u0446\u0438\u044f \u0441\u0446\u0435\u043d\u044b \u043d\u0430 {subtype}", - "state.node_status": "\u0421\u0442\u0430\u0442\u0443\u0441 \u0443\u0437\u043b\u0430 \u0438\u0437\u043c\u0435\u043d\u0435\u043d" + "state.node_status": "\u0421\u0442\u0430\u0442\u0443\u0441 \u0443\u0437\u043b\u0430 \u0438\u0437\u043c\u0435\u043d\u0435\u043d", + "zwave_js.value_updated.config_parameter": "\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {subtype}", + "zwave_js.value_updated.value": "\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f Z-Wave JS Value" } }, "options": { diff --git a/homeassistant/components/zwave_js/translations/zh-Hant.json b/homeassistant/components/zwave_js/translations/zh-Hant.json index 8b1c4caff1f..e9038ed9a00 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -8,7 +8,9 @@ "addon_start_failed": "Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u59cb\u5931\u6557\u3002", "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", - "cannot_connect": "\u9023\u7dda\u5931\u6557" + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "discovery_requires_supervisor": "\u63a2\u7d22\u529f\u80fd\u9700\u8981 Supervisor \u6b0a\u9650\u3002", + "not_zwave_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Z-Wave \u88dd\u7f6e" }, "error": { "addon_start_failed": "Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u52d5\u5931\u6557\uff0c\u8acb\u6aa2\u67e5\u8a2d\u5b9a\u3002", @@ -16,6 +18,7 @@ "invalid_ws_url": "Websocket URL \u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, + "flow_title": "{name}", "progress": { "install_addon": "\u8acb\u7a0d\u7b49 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u5b89\u88dd\u5b8c\u6210\uff0c\u53ef\u80fd\u6703\u9700\u8981\u5e7e\u5206\u9418\u3002", "start_addon": "\u8acb\u7a0d\u7b49 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u59cb\u5b8c\u6210\uff0c\u53ef\u80fd\u6703\u9700\u8981\u5e7e\u5206\u9418\u3002" @@ -48,6 +51,9 @@ }, "start_addon": { "title": "Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u59cb\u4e2d\u3002" + }, + "usb_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u540d\u70ba {name} \u7684 Z-Wave JS \u9644\u52a0\u5143\u4ef6\uff1f" } } }, @@ -63,7 +69,9 @@ "event.value_notification.basic": "{subtype} \u4e0a\u57fa\u672c CC \u4e8b\u4ef6", "event.value_notification.central_scene": "{subtype} \u4e0a\u6838\u5fc3\u5834\u666f\u52d5\u4f5c", "event.value_notification.scene_activation": "{subtype} \u4e0a\u5834\u666f\u5df2\u555f\u52d5", - "state.node_status": "\u7bc0\u9ede\u72c0\u614b\u5df2\u6539\u8b8a" + "state.node_status": "\u7bc0\u9ede\u72c0\u614b\u5df2\u6539\u8b8a", + "zwave_js.value_updated.config_parameter": "\u8a2d\u5b9a\u53c3\u6578 {subtype} \u6578\u503c\u8b8a\u66f4", + "zwave_js.value_updated.value": "Z-Wave JS \u503c\u4e0a\u7684\u6578\u503c\u8b8a\u66f4" } }, "options": { diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py new file mode 100644 index 00000000000..69e770e3817 --- /dev/null +++ b/homeassistant/components/zwave_js/trigger.py @@ -0,0 +1,54 @@ +"""Z-Wave JS trigger dispatcher.""" +from __future__ import annotations + +from types import ModuleType +from typing import Any, Callable, cast + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .triggers import value_updated + +TRIGGERS = { + "value_updated": value_updated, +} + + +def _get_trigger_platform(config: ConfigType) -> ModuleType: + """Return trigger platform.""" + platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) + if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: + raise ValueError(f"Unknown Z-Wave JS trigger platform {config[CONF_PLATFORM]}") + return TRIGGERS[platform_split[1]] + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + if hasattr(platform, "async_validate_trigger_config"): + return cast( + ConfigType, + await getattr(platform, "async_validate_trigger_config")(hass, config), + ) + assert hasattr(platform, "TRIGGER_SCHEMA") + return cast(ConfigType, getattr(platform, "TRIGGER_SCHEMA")(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: Callable, + automation_info: dict[str, Any], +) -> Callable: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + assert hasattr(platform, "async_attach_trigger") + return cast( + Callable, + await getattr(platform, "async_attach_trigger")( + hass, config, action, automation_info + ), + ) diff --git a/homeassistant/components/zwave_js/triggers/__init__.py b/homeassistant/components/zwave_js/triggers/__init__.py new file mode 100644 index 00000000000..7c4f867d465 --- /dev/null +++ b/homeassistant/components/zwave_js/triggers/__init__.py @@ -0,0 +1 @@ +"""Z-Wave JS triggers.""" diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py new file mode 100644 index 00000000000..a2dbb84cf3b --- /dev/null +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -0,0 +1,193 @@ +"""Offer Z-Wave JS value updated listening automation rules.""" +from __future__ import annotations + +import functools +import logging +from typing import Any, Callable + +import voluptuous as vol +from zwave_js_server.const import CommandClass +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.zwave_js.const import ( + ATTR_COMMAND_CLASS, + ATTR_COMMAND_CLASS_NAME, + ATTR_CURRENT_VALUE, + ATTR_CURRENT_VALUE_RAW, + ATTR_ENDPOINT, + ATTR_NODE_ID, + ATTR_PREVIOUS_VALUE, + ATTR_PREVIOUS_VALUE_RAW, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_PROPERTY_KEY_NAME, + ATTR_PROPERTY_NAME, + DOMAIN, +) +from homeassistant.components.zwave_js.helpers import ( + async_get_node_from_device_id, + async_get_node_from_entity_id, + get_device_id, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM, MATCH_ALL +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +# Platform type should be . +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +ATTR_FROM = "from" +ATTR_TO = "to" + +VALUE_SCHEMA = vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + cv.boolean, + cv.string, +) + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_COMMAND_CLASS): vol.In( + {cc.value: cc.name for cc in CommandClass} + ), + vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_FROM, default=MATCH_ALL): vol.Any( + VALUE_SCHEMA, [VALUE_SCHEMA] + ), + vol.Optional(ATTR_TO, default=MATCH_ALL): vol.Any( + VALUE_SCHEMA, [VALUE_SCHEMA] + ), + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: Callable, + automation_info: dict[str, Any], + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + nodes: set[Node] = set() + if ATTR_DEVICE_ID in config: + nodes.update( + { + async_get_node_from_device_id(hass, device_id) + for device_id in config.get(ATTR_DEVICE_ID, []) + } + ) + if ATTR_ENTITY_ID in config: + nodes.update( + { + async_get_node_from_entity_id(hass, entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + from_value = config[ATTR_FROM] + to_value = config[ATTR_TO] + command_class = config[ATTR_COMMAND_CLASS] + property_ = config[ATTR_PROPERTY] + endpoint = config.get(ATTR_ENDPOINT) + property_key = config.get(ATTR_PROPERTY_KEY) + unsubs = [] + job = HassJob(action) + + trigger_data: dict = {} + if automation_info: + trigger_data = automation_info.get("trigger_data", {}) + + @callback + def async_on_value_updated( + value: Value, device: dr.DeviceEntry, event: Event + ) -> None: + """Handle value update.""" + event_value: Value = event["value"] + if event_value != value: + return + + # Get previous value and its state value if it exists + prev_value_raw = event["args"]["prevValue"] + prev_value = value.metadata.states.get(str(prev_value_raw), prev_value_raw) + # Get current value and its state value if it exists + curr_value_raw = event["args"]["newValue"] + curr_value = value.metadata.states.get(str(curr_value_raw), curr_value_raw) + # Check from and to values against previous and current values respectively + for value_to_eval, raw_value_to_eval, match in ( + (prev_value, prev_value_raw, from_value), + (curr_value, curr_value_raw, to_value), + ): + if ( + match != MATCH_ALL + and value_to_eval != match + and not ( + isinstance(match, list) + and (value_to_eval in match or raw_value_to_eval in match) + ) + and raw_value_to_eval != match + ): + return + + device_name = device.name_by_user or device.name + + payload = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device.id, + ATTR_NODE_ID: value.node.node_id, + ATTR_COMMAND_CLASS: value.command_class, + ATTR_COMMAND_CLASS_NAME: value.command_class_name, + ATTR_PROPERTY: value.property_, + ATTR_PROPERTY_NAME: value.property_name, + ATTR_ENDPOINT: endpoint, + ATTR_PROPERTY_KEY: value.property_key, + ATTR_PROPERTY_KEY_NAME: value.property_key_name, + ATTR_PREVIOUS_VALUE: prev_value, + ATTR_PREVIOUS_VALUE_RAW: prev_value_raw, + ATTR_CURRENT_VALUE: curr_value, + ATTR_CURRENT_VALUE_RAW: curr_value_raw, + "description": f"Z-Wave value {value_id} updated on {device_name}", + } + + hass.async_run_hass_job(job, {"trigger": payload}) + + dev_reg = dr.async_get(hass) + for node in nodes: + device_identifier = get_device_id(node.client, node) + device = dev_reg.async_get_device({device_identifier}) + assert device + value_id = get_value_id(node, command_class, property_, endpoint, property_key) + value = node.values[value_id] + # We need to store the current value and device for the callback + unsubs.append( + node.on( + "value updated", + functools.partial(async_on_value_updated, value, device), + ) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/homeassistant/config.py b/homeassistant/config.py index 12a39ab291b..754420dbcce 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -10,6 +10,7 @@ import re import shutil from types import ModuleType from typing import Any, Callable +from urllib.parse import urlparse from awesomeversion import AwesomeVersion import voluptuous as vol @@ -161,6 +162,19 @@ def _no_duplicate_auth_mfa_module( return configs +def _filter_bad_internal_external_urls(conf: dict) -> dict: + """Filter internal/external URL with a path.""" + for key in CONF_INTERNAL_URL, CONF_EXTERNAL_URL: + if key in conf and urlparse(conf[key]).path not in ("", "/"): + # We warn but do not fix, because if this was incorrectly configured, + # adjusting this value might impact security. + _LOGGER.warning( + "Invalid %s set. It's not allowed to have a path (/bla)", key + ) + + return conf + + PACKAGES_CONFIG_SCHEMA = cv.schema_with_slug_keys( # Package names are slugs vol.Schema({cv.string: vol.Any(dict, list, None)}) # Component config ) @@ -188,59 +202,64 @@ CUSTOMIZE_CONFIG_SCHEMA = vol.Schema( } ) -CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend( - { - CONF_NAME: vol.Coerce(str), - CONF_LATITUDE: cv.latitude, - CONF_LONGITUDE: cv.longitude, - CONF_ELEVATION: vol.Coerce(int), - vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, - CONF_UNIT_SYSTEM: cv.unit_system, - CONF_TIME_ZONE: cv.time_zone, - vol.Optional(CONF_INTERNAL_URL): cv.url, - vol.Optional(CONF_EXTERNAL_URL): cv.url, - vol.Optional(CONF_ALLOWLIST_EXTERNAL_DIRS): vol.All( - cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter - ), - vol.Optional(LEGACY_CONF_WHITELIST_EXTERNAL_DIRS): vol.All( - cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter - ), - vol.Optional(CONF_ALLOWLIST_EXTERNAL_URLS): vol.All(cv.ensure_list, [cv.url]), - vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, - vol.Optional(CONF_AUTH_PROVIDERS): vol.All( - cv.ensure_list, - [ - auth_providers.AUTH_PROVIDER_SCHEMA.extend( - { - CONF_TYPE: vol.NotIn( - ["insecure_example"], - "The insecure_example auth provider" - " is for testing only.", - ) - } - ) - ], - _no_duplicate_auth_provider, - ), - vol.Optional(CONF_AUTH_MFA_MODULES): vol.All( - cv.ensure_list, - [ - auth_mfa_modules.MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend( - { - CONF_TYPE: vol.NotIn( - ["insecure_example"], - "The insecure_example mfa module is for testing only.", - ) - } - ) - ], - _no_duplicate_auth_mfa_module, - ), - # pylint: disable=no-value-for-parameter - vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), - vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, - vol.Optional(CONF_CURRENCY): cv.currency, - } +CORE_CONFIG_SCHEMA = vol.All( + CUSTOMIZE_CONFIG_SCHEMA.extend( + { + CONF_NAME: vol.Coerce(str), + CONF_LATITUDE: cv.latitude, + CONF_LONGITUDE: cv.longitude, + CONF_ELEVATION: vol.Coerce(int), + vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + CONF_UNIT_SYSTEM: cv.unit_system, + CONF_TIME_ZONE: cv.time_zone, + vol.Optional(CONF_INTERNAL_URL): cv.url, + vol.Optional(CONF_EXTERNAL_URL): cv.url, + vol.Optional(CONF_ALLOWLIST_EXTERNAL_DIRS): vol.All( + cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter + ), + vol.Optional(LEGACY_CONF_WHITELIST_EXTERNAL_DIRS): vol.All( + cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter + ), + vol.Optional(CONF_ALLOWLIST_EXTERNAL_URLS): vol.All( + cv.ensure_list, [cv.url] + ), + vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, + vol.Optional(CONF_AUTH_PROVIDERS): vol.All( + cv.ensure_list, + [ + auth_providers.AUTH_PROVIDER_SCHEMA.extend( + { + CONF_TYPE: vol.NotIn( + ["insecure_example"], + "The insecure_example auth provider" + " is for testing only.", + ) + } + ) + ], + _no_duplicate_auth_provider, + ), + vol.Optional(CONF_AUTH_MFA_MODULES): vol.All( + cv.ensure_list, + [ + auth_mfa_modules.MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend( + { + CONF_TYPE: vol.NotIn( + ["insecure_example"], + "The insecure_example mfa module is for testing only.", + ) + } + ) + ], + _no_duplicate_auth_mfa_module, + ), + # pylint: disable=no-value-for-parameter + vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), + vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, + vol.Optional(CONF_CURRENCY): cv.currency, + } + ), + _filter_bad_internal_external_urls, ) @@ -906,7 +925,7 @@ async def async_process_component_config( # noqa: C901 @callback -def config_without_domain(config: dict, domain: str) -> dict: +def config_without_domain(config: ConfigType, domain: str) -> ConfigType: """Return a config with all configuration for a domain removed.""" filter_keys = extract_domain_configs(config, domain) return {key: value for key, value in config.items() if key not in filter_keys} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ec074f81b95..50d279ec8b0 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -21,7 +21,12 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.event import Event -from homeassistant.helpers.typing import UNDEFINED, DiscoveryInfoType, UndefinedType +from homeassistant.helpers.typing import ( + UNDEFINED, + ConfigType, + DiscoveryInfoType, + UndefinedType, +) from homeassistant.setup import async_process_deps_reqs, async_setup_component from homeassistant.util.decorator import Registry import homeassistant.util.uuid as uuid_util @@ -35,6 +40,7 @@ SOURCE_IMPORT = "import" SOURCE_INTEGRATION_DISCOVERY = "integration_discovery" SOURCE_MQTT = "mqtt" SOURCE_SSDP = "ssdp" +SOURCE_USB = "usb" SOURCE_USER = "user" SOURCE_ZEROCONF = "zeroconf" SOURCE_DHCP = "dhcp" @@ -98,7 +104,12 @@ DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = ( SOURCE_SSDP, + SOURCE_USB, + SOURCE_DHCP, + SOURCE_HOMEKIT, SOURCE_ZEROCONF, + SOURCE_HOMEKIT, + SOURCE_DHCP, SOURCE_DISCOVERY, SOURCE_IMPORT, SOURCE_UNIGNORE, @@ -598,7 +609,10 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): """Manage all the config entry flows that are in progress.""" def __init__( - self, hass: HomeAssistant, config_entries: ConfigEntries, hass_config: dict + self, + hass: HomeAssistant, + config_entries: ConfigEntries, + hass_config: ConfigType, ) -> None: """Initialize the config entry flow manager.""" super().__init__(hass) @@ -748,7 +762,7 @@ class ConfigEntries: An instance of this object is available via `hass.config_entries`. """ - def __init__(self, hass: HomeAssistant, hass_config: dict) -> None: + def __init__(self, hass: HomeAssistant, hass_config: ConfigType) -> None: """Initialize the entry manager.""" self.hass = hass self.flow = ConfigEntriesFlowManager(hass, self, hass_config) @@ -1191,7 +1205,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): return None if raise_on_progress: - for progress in self._async_in_progress(): + for progress in self._async_in_progress(include_uninitialized=True): if progress["context"].get("unique_id") == unique_id: raise data_entry_flow.AbortFlow("already_in_progress") @@ -1199,7 +1213,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): # Abort discoveries done using the default discovery unique id if unique_id != DEFAULT_DISCOVERY_UNIQUE_ID: - for progress in self._async_in_progress(): + for progress in self._async_in_progress(include_uninitialized=True): if progress["context"].get("unique_id") == DEFAULT_DISCOVERY_UNIQUE_ID: self.hass.config_entries.flow.async_abort(progress["flow_id"]) @@ -1362,6 +1376,12 @@ class ConfigFlow(data_entry_flow.FlowHandler): """Handle a flow initialized by DHCP discovery.""" return await self.async_step_discovery(discovery_info) + async def async_step_usb( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: + """Handle a flow initialized by USB discovery.""" + return await self.async_step_discovery(discovery_info) + @callback def async_create_entry( # pylint: disable=arguments-differ self, diff --git a/homeassistant/const.py b/homeassistant/const.py index 667c9c11389..9cd5ecc4fec 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 = 8 -PATCH_VERSION: Final = "8" +MINOR_VERSION: Final = 9 +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) @@ -232,6 +232,7 @@ EVENT_TIME_CHANGED: Final = "time_changed" # #### DEVICE CLASSES #### +DEVICE_CLASS_AQI: Final = "aqi" DEVICE_CLASS_BATTERY: Final = "battery" DEVICE_CLASS_CO: Final = "carbon_monoxide" DEVICE_CLASS_CO2: Final = "carbon_dioxide" @@ -240,13 +241,23 @@ DEVICE_CLASS_ENERGY: Final = "energy" DEVICE_CLASS_HUMIDITY: Final = "humidity" DEVICE_CLASS_ILLUMINANCE: Final = "illuminance" DEVICE_CLASS_MONETARY: Final = "monetary" +DEVICE_CLASS_NITROGEN_DIOXIDE = "nitrogen_dioxide" +DEVICE_CLASS_NITROGEN_MONOXIDE = "nitrogen_monoxide" +DEVICE_CLASS_NITROUS_OXIDE = "nitrous_oxide" +DEVICE_CLASS_OZONE: Final = "ozone" DEVICE_CLASS_POWER_FACTOR: Final = "power_factor" DEVICE_CLASS_POWER: Final = "power" +DEVICE_CLASS_PM25: Final = "pm25" +DEVICE_CLASS_PM1: Final = "pm1" +DEVICE_CLASS_PM10: Final = "pm10" DEVICE_CLASS_PRESSURE: Final = "pressure" DEVICE_CLASS_SIGNAL_STRENGTH: Final = "signal_strength" +DEVICE_CLASS_SULPHUR_DIOXIDE = "sulphur_dioxide" DEVICE_CLASS_TEMPERATURE: Final = "temperature" DEVICE_CLASS_TIMESTAMP: Final = "timestamp" DEVICE_CLASS_VOLTAGE: Final = "voltage" +DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" +DEVICE_CLASS_GAS: Final = "gas" # #### STATES #### STATE_ON: Final = "on" @@ -612,6 +623,7 @@ SERVICE_CLOSE_COVER: Final = "close_cover" SERVICE_CLOSE_COVER_TILT: Final = "close_cover_tilt" SERVICE_OPEN_COVER: Final = "open_cover" SERVICE_OPEN_COVER_TILT: Final = "open_cover_tilt" +SERVICE_SAVE_PERSISTENT_STATES: Final = "save_persistent_states" SERVICE_SET_COVER_POSITION: Final = "set_cover_position" SERVICE_SET_COVER_TILT_POSITION: Final = "set_cover_tilt_position" SERVICE_STOP_COVER: Final = "stop_cover" diff --git a/homeassistant/core.py b/homeassistant/core.py index e2418321592..1b1849ba548 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -19,6 +19,7 @@ import threading from time import monotonic from types import MappingProxyType from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast +from urllib.parse import urlparse import attr import voluptuous as vol @@ -1717,19 +1718,35 @@ class Config: ) data = await store.async_load() - if data: - self._update( - source=SOURCE_STORAGE, - latitude=data.get("latitude"), - longitude=data.get("longitude"), - elevation=data.get("elevation"), - unit_system=data.get("unit_system"), - location_name=data.get("location_name"), - time_zone=data.get("time_zone"), - external_url=data.get("external_url", _UNDEF), - internal_url=data.get("internal_url", _UNDEF), - currency=data.get("currency"), - ) + if not data: + return + + # In 2021.9 we fixed validation to disallow a path (because that's never correct) + # but this data still lives in storage, so we print a warning. + if data.get("external_url") and urlparse(data["external_url"]).path not in ( + "", + "/", + ): + _LOGGER.warning("Invalid external_url set. It's not allowed to have a path") + + if data.get("internal_url") and urlparse(data["internal_url"]).path not in ( + "", + "/", + ): + _LOGGER.warning("Invalid internal_url set. It's not allowed to have a path") + + self._update( + source=SOURCE_STORAGE, + latitude=data.get("latitude"), + longitude=data.get("longitude"), + elevation=data.get("elevation"), + unit_system=data.get("unit_system"), + location_name=data.get("location_name"), + time_zone=data.get("time_zone"), + external_url=data.get("external_url", _UNDEF), + internal_url=data.get("internal_url", _UNDEF), + currency=data.get("currency"), + ) async def async_store(self) -> None: """Store [homeassistant] core config.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 844fd369cac..2a82c2652ed 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -9,6 +9,8 @@ import attr if TYPE_CHECKING: from .core import Context +# mypy: disallow-any-generics + class HomeAssistantError(Exception): """General Home Assistant exception occurred.""" @@ -42,7 +44,7 @@ class ConditionError(HomeAssistantError): """Return indentation.""" return " " * indent + message - def output(self, indent: int) -> Generator: + def output(self, indent: int) -> Generator[str, None, None]: """Yield an indented representation.""" raise NotImplementedError() @@ -58,7 +60,7 @@ class ConditionErrorMessage(ConditionError): # A message describing this error message: str = attr.ib() - def output(self, indent: int) -> Generator: + def output(self, indent: int) -> Generator[str, None, None]: """Yield an indented representation.""" yield self._indent(indent, f"In '{self.type}' condition: {self.message}") @@ -74,7 +76,7 @@ class ConditionErrorIndex(ConditionError): # The error that this error wraps error: ConditionError = attr.ib() - def output(self, indent: int) -> Generator: + def output(self, indent: int) -> Generator[str, None, None]: """Yield an indented representation.""" if self.total > 1: yield self._indent( @@ -93,7 +95,7 @@ class ConditionErrorContainer(ConditionError): # List of ConditionErrors that this error wraps errors: Sequence[ConditionError] = attr.ib() - def output(self, indent: int) -> Generator: + def output(self, indent: int) -> Generator[str, None, None]: """Yield an indented representation.""" for item in self.errors: yield from item.output(indent) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4cb9e2e3c4b..2eb4e43fe32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,6 +16,7 @@ FLOWS = [ "agent_dvr", "airly", "airnow", + "airtouch4", "airvisual", "alarmdecoder", "almond", @@ -76,6 +77,7 @@ FLOWS = [ "ezviz", "faa_delays", "fireservicerota", + "fjaraskupan", "flick_electric", "flipr", "flo", @@ -128,6 +130,7 @@ FLOWS = [ "ifttt", "insteon", "ios", + "iotawatt", "ipma", "ipp", "iqvia", @@ -174,12 +177,14 @@ FLOWS = [ "myq", "mysensors", "nam", + "nanoleaf", "neato", "nest", "netatmo", "nexia", "nfandroidtv", "nightscout", + "nmap_tracker", "notion", "nuheat", "nuki", @@ -196,6 +201,7 @@ FLOWS = [ "ovo_energy", "owntracks", "ozw", + "p1_monitor", "panasonic_viera", "philips_js", "pi_hole", @@ -213,6 +219,7 @@ FLOWS = [ "ps4", "pvpc_hourly_pricing", "rachio", + "rainforest_eagle", "rainmachine", "recollect_waste", "renault", @@ -272,6 +279,7 @@ FLOWS = [ "totalconnect", "tplink", "traccar", + "tractive", "tradfri", "transmission", "tuya", @@ -282,6 +290,7 @@ FLOWS = [ "upb", "upcloud", "upnp", + "uptimerobot", "velbus", "vera", "verisure", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index dbdaaf6da5e..cf442504121 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -16,6 +16,11 @@ DHCP = [ "hostname": "connect", "macaddress": "B8B7F1*" }, + { + "domain": "august", + "hostname": "connect", + "macaddress": "2C9FFB*" + }, { "domain": "august", "hostname": "august*", @@ -156,6 +161,10 @@ DHCP = [ "hostname": "rachio-*", "macaddress": "74C63B*" }, + { + "domain": "rainforest_eagle", + "macaddress": "D8D5B9*" + }, { "domain": "ring", "hostname": "ring*", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py new file mode 100644 index 00000000000..477a762ae62 --- /dev/null +++ b/homeassistant/generated/usb.py @@ -0,0 +1,42 @@ +"""Automatically generated by hassfest. + +To update, run python3 -m script.hassfest +""" + +# fmt: off + +USB = [ + { + "domain": "zha", + "vid": "10C4", + "pid": "EA60", + "description": "*2652*" + }, + { + "domain": "zha", + "vid": "1CF1", + "pid": "0030", + "description": "*conbee*" + }, + { + "domain": "zha", + "vid": "10C4", + "pid": "8A2A", + "description": "*zigbee*" + }, + { + "domain": "zwave_js", + "vid": "0658", + "pid": "0200" + }, + { + "domain": "zwave_js", + "vid": "10C4", + "pid": "8A2A" + }, + { + "domain": "zwave_js", + "vid": "10C4", + "pid": "EA60" + } +] diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 536485f7f55..fd5194bd025 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -165,6 +165,16 @@ ZEROCONF = { "domain": "xiaomi_miio" } ], + "_nanoleafapi._tcp.local.": [ + { + "domain": "nanoleaf" + } + ], + "_nanoleafms._tcp.local.": [ + { + "domain": "nanoleaf" + } + ], "_nut._tcp.local.": [ { "domain": "nut" @@ -251,6 +261,7 @@ HOMEKIT = { "Iota": "abode", "LIFX": "lifx", "MYQ": "myq", + "NL*": "nanoleaf", "Netatmo Relay": "netatmo", "PowerView": "hunterdouglas_powerview", "Presence": "netatmo", @@ -262,7 +273,7 @@ HOMEKIT = { "Touch HD": "rainmachine", "Welcome": "netatmo", "Wemo": "wemo", - "YLDP*": "yeelight", + "YL*": "yeelight", "iSmartGate": "gogogate2", "iZone": "izone", "tado": "tado" diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 6f5e7c40d22..f90086f87ee 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -69,6 +69,8 @@ from .trace import ( trace_stack_top, ) +# mypy: disallow-any-generics + FROM_CONFIG_FORMAT = "{}_from_config" ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" @@ -113,7 +115,7 @@ def condition_trace_update_result(**kwargs: Any) -> None: @contextmanager -def trace_condition(variables: TemplateVarsType) -> Generator: +def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement, None, None]: """Trace condition evaluation.""" should_pop = True trace_element = trace_stack_top(trace_stack_cv) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 66d1c01d6d3..3a2fb6c70e4 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -120,7 +120,7 @@ def path(value: Any) -> str: # Adapted from: # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 -def has_at_least_one_key(*keys: str) -> Callable: +def has_at_least_one_key(*keys: Any) -> Callable[[dict], dict]: """Validate that at least one key exists.""" def validate(obj: dict) -> dict: @@ -131,12 +131,13 @@ def has_at_least_one_key(*keys: str) -> Callable: for k in obj: if k in keys: return obj - raise vol.Invalid("must contain at least one of {}.".format(", ".join(keys))) + expected = ", ".join(str(k) for k in keys) + raise vol.Invalid(f"must contain at least one of {expected}.") return validate -def has_at_most_one_key(*keys: str) -> Callable[[dict], dict]: +def has_at_most_one_key(*keys: Any) -> Callable[[dict], dict]: """Validate that zero keys exist or one key exists.""" def validate(obj: dict) -> dict: @@ -145,7 +146,8 @@ def has_at_most_one_key(*keys: str) -> Callable[[dict], dict]: raise vol.Invalid("expected dictionary") if len(set(keys) & set(obj)) > 1: - raise vol.Invalid("must contain at most one of {}.".format(", ".join(keys))) + expected = ", ".join(str(k) for k in keys) + raise vol.Invalid(f"must contain at most one of {expected}.") return obj return validate @@ -649,6 +651,16 @@ def url(value: Any) -> str: raise vol.Invalid("invalid url") +def url_no_path(value: Any) -> str: + """Validate a url without a path.""" + url_in = url(value) + + if urlparse(url_in).path not in ("", "/"): + raise vol.Invalid("url it not allowed to have a path component") + + return url_in + + def x10_address(value: str) -> str: """Validate an x10 address.""" regex = re.compile(r"([A-Pa-p]{1})(?:[2-9]|1[0-6]?)$") @@ -1297,6 +1309,7 @@ currency = vol.In( "BSD", "BTN", "BWP", + "BYN", "BYR", "BZD", "CAD", diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index adf3d8a5d88..e20748913ba 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -87,7 +87,7 @@ def deprecated_class(replacement: str) -> Any: """Decorate class as deprecated.""" @functools.wraps(cls) - def deprecated_cls(*args: tuple, **kwargs: dict[str, Any]) -> Any: + def deprecated_cls(*args: Any, **kwargs: Any) -> Any: """Wrap for the original class.""" _print_deprecation_warning(cls, replacement, "class") return cls(*args, **kwargs) @@ -104,7 +104,7 @@ def deprecated_function(replacement: str) -> Callable[..., Callable]: """Decorate function as deprecated.""" @functools.wraps(func) - def deprecated_func(*args: tuple, **kwargs: dict[str, Any]) -> Any: + def deprecated_func(*args: Any, **kwargs: Any) -> Any: """Wrap for the original function.""" _print_deprecation_warning(func, replacement, "function") return func(*args, **kwargs) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 6383de15b4a..847dc062764 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -165,13 +165,13 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: class DeviceInfo(TypedDict, total=False): """Entity device information for device registry.""" - name: str + name: str | None connections: set[tuple[str, str]] identifiers: set[tuple[str, str]] - manufacturer: str - model: str - suggested_area: str - sw_version: str + manufacturer: str | None + model: str | None + suggested_area: str | None + sw_version: str | None via_device: tuple[str, str] entry_type: str | None default_name: str @@ -539,25 +539,13 @@ class Entity(ABC): if end - start > 0.4 and not self._slow_reported: self._slow_reported = True - extra = "" - if "custom_components" in type(self).__module__: - extra = "Please report it to the custom component author." - else: - extra = ( - "Please create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) - if self.platform: - extra += ( - f"+label%3A%22integration%3A+{self.platform.platform_name}%22" - ) - + report_issue = self._suggest_report_issue() _LOGGER.warning( - "Updating state for %s (%s) took %.3f seconds. %s", + "Updating state for %s (%s) took %.3f seconds. Please %s", self.entity_id, type(self), end - start, - extra, + report_issue, ) # Overwrite properties that have been set in the config file. @@ -634,7 +622,6 @@ class Entity(ABC): await self.parallel_updates.acquire() try: - # pylint: disable=no-member if hasattr(self, "async_update"): task = self.hass.async_create_task(self.async_update()) # type: ignore elif hasattr(self, "update"): @@ -858,6 +845,23 @@ class Entity(ABC): if self.parallel_updates: self.parallel_updates.release() + def _suggest_report_issue(self) -> str: + """Suggest to report an issue.""" + report_issue = "" + if "custom_components" in type(self).__module__: + 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 self.platform: + report_issue += ( + f"+label%3A%22integration%3A+{self.platform.platform_name}%22" + ) + + return report_issue + @dataclass class ToggleEntityDescription(EntityDescription): diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index ab54d159f5e..5d5f71d2fd5 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -71,8 +71,8 @@ class TrackStates: """ all_states: bool - entities: set - domains: set + entities: set[str] + domains: set[str] @dataclass @@ -394,7 +394,7 @@ def async_track_entity_registry_updated_event( @callback def _async_dispatch_domain_event( - hass: HomeAssistant, event: Event, callbacks: dict[str, list] + hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob]] ) -> None: domain = split_entity_id(event.data["entity_id"])[0] @@ -620,7 +620,7 @@ class _TrackStateChangeFiltered: self._listeners.pop(listener_name)() @callback - def _setup_entities_listener(self, domains: set, entities: set) -> None: + def _setup_entities_listener(self, domains: set[str], entities: set[str]) -> None: if domains: entities = entities.copy() entities.update(self.hass.states.async_entity_ids(domains)) @@ -634,7 +634,7 @@ class _TrackStateChangeFiltered: ) @callback - def _setup_domains_listener(self, domains: set) -> None: + def _setup_domains_listener(self, domains: set[str]) -> None: if not domains: return diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 5becda0545b..57a81083c50 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -23,7 +23,7 @@ async def async_process_integration_platforms( """Process a specific platform for all current and future loaded integrations.""" async def _process(component_name: str) -> None: - """Process the intents of a component.""" + """Process component being loaded.""" if "." in component_name: return diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index da6c6935b35..cedd07676ba 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable import logging +from typing import Any from homeassistant import config as conf_util from homeassistant.const import SERVICE_RELOAD @@ -15,11 +16,13 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) async def async_reload_integration_platforms( - hass: HomeAssistant, integration_name: str, integration_platforms: Iterable + hass: HomeAssistant, integration_name: str, integration_platforms: Iterable[str] ) -> None: """Reload an integration's platforms. @@ -62,7 +65,7 @@ async def _resetup_platform( if not conf: return - root_config: dict = {integration_platform: []} + root_config: dict[str, Any] = {integration_platform: []} # Extract only the config for template, ignore the rest. for p_type, p_config in config_per_platform(conf, integration_platform): if p_type != integration_name: @@ -102,7 +105,7 @@ async def _async_setup_platform( hass: HomeAssistant, integration_name: str, integration_platform: str, - platform_configs: list[dict], + platform_configs: list[dict[str, Any]], ) -> None: """Platform for the first time when new configuration is added.""" if integration_platform not in hass.data: @@ -120,7 +123,7 @@ async def _async_setup_platform( async def _async_reconfig_platform( - platform: EntityPlatform, platform_configs: list[dict] + platform: EntityPlatform, platform_configs: list[dict[str, Any]] ) -> None: """Reconfigure an already loaded platform.""" await platform.async_reset() @@ -155,7 +158,7 @@ def async_get_platform_without_config_entry( async def async_setup_reload_service( - hass: HomeAssistant, domain: str, platforms: Iterable + hass: HomeAssistant, domain: str, platforms: Iterable[str] ) -> None: """Create the reload service for the domain.""" if hass.services.has_service(domain, SERVICE_RELOAD): @@ -171,7 +174,9 @@ async def async_setup_reload_service( ) -def setup_reload_service(hass: HomeAssistant, domain: str, platforms: Iterable) -> None: +def setup_reload_service( + hass: HomeAssistant, domain: str, platforms: Iterable[str] +) -> None: """Sync version of async_setup_reload_service.""" asyncio.run_coroutine_threadsafe( async_setup_reload_service(hass, domain, platforms), diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 67b2d329af1..da4d2bacf15 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -100,6 +100,12 @@ class RestoreStateData: return cast(RestoreStateData, await load_instance(hass)) + @classmethod + async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: + """Dump states now.""" + data = await cls.async_get_instance(hass) + await data.async_dump_states() + def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" self.hass: HomeAssistant = hass diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 23241f22d1e..3dae84166f6 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -8,6 +8,8 @@ from homeassistant.core import HomeAssistant, callback from . import template +# mypy: disallow-any-generics + class ScriptVariables: """Class to hold and render script variables.""" @@ -65,6 +67,6 @@ class ScriptVariables: return rendered_variables - def as_dict(self) -> dict: + def as_dict(self) -> dict[str, Any]: """Return dict version of this class.""" return self.variables diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 66354aa7aa6..ade580694c8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -22,7 +22,7 @@ from urllib.parse import urlencode as urllib_urlencode import weakref import jinja2 -from jinja2 import contextfunction, pass_context +from jinja2 import pass_context from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace import voluptuous as vol @@ -44,6 +44,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import TemplateError from homeassistant.helpers import ( + area_registry, device_registry, entity_registry, location as loc_helper, @@ -151,7 +152,7 @@ def gen_result_wrapper(kls): class Wrapper(kls, ResultWrapper): """Wrapper of a kls that can store render_result.""" - def __init__(self, *args: tuple, render_result: str | None = None) -> None: + def __init__(self, *args: Any, render_result: str | None = None) -> None: super().__init__(*args) self.render_result = render_result @@ -949,6 +950,84 @@ def is_device_attr( return bool(device_attr(hass, device_or_entity_id, attr_name) == attr_value) +def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the area ID from an area name, device id, or entity id.""" + area_reg = area_registry.async_get(hass) + if area := area_reg.async_get_area_by_name(str(lookup_value)): + return area.id + + ent_reg = entity_registry.async_get(hass) + dev_reg = device_registry.async_get(hass) + # Import here, not at top-level to avoid circular import + from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel + config_validation as cv, + ) + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + # If entity has an area ID, return that + if entity.area_id: + return entity.area_id + # If entity has a device ID, return the area ID for the device + if entity.device_id and (device := dev_reg.async_get(entity.device_id)): + return device.area_id + + # Check if this could be a device ID + if device := dev_reg.async_get(lookup_value): + return device.area_id + + return None + + +def _get_area_name(area_reg: area_registry.AreaRegistry, valid_area_id: str) -> str: + """Get area name from valid area ID.""" + area = area_reg.async_get_area(valid_area_id) + assert area + return area.name + + +def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the area name from an area id, device id, or entity id.""" + area_reg = area_registry.async_get(hass) + area = area_reg.async_get_area(lookup_value) + if area: + return area.name + + dev_reg = device_registry.async_get(hass) + ent_reg = entity_registry.async_get(hass) + # Import here, not at top-level to avoid circular import + from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel + config_validation as cv, + ) + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + # If entity has an area ID, get the area name for that + if entity.area_id: + return _get_area_name(area_reg, entity.area_id) + # If entity has a device ID and the device exists with an area ID, get the + # area name for that + if ( + entity.device_id + and (device := dev_reg.async_get(entity.device_id)) + and device.area_id + ): + return _get_area_name(area_reg, device.area_id) + + if (device := dev_reg.async_get(lookup_value)) and device.area_id: + return _get_area_name(area_reg, device.area_id) + + return None + + def closest(hass, *args): """Find closest entity. @@ -1521,7 +1600,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): def wrapper(*args, **kwargs): return func(hass, *args[1:], **kwargs) - return contextfunction(wrapper) + return pass_context(wrapper) self.globals["device_entities"] = hassfunction(device_entities) self.filters["device_entities"] = pass_context(self.globals["device_entities"]) @@ -1532,6 +1611,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["device_id"] = hassfunction(device_id) self.filters["device_id"] = pass_context(self.globals["device_id"]) + self.globals["area_id"] = hassfunction(area_id) + self.filters["area_id"] = pass_context(self.globals["area_id"]) + + self.globals["area_name"] = hassfunction(area_name) + self.filters["area_name"] = pass_context(self.globals["area_name"]) + if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. @@ -1556,8 +1641,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "device_attr", "is_device_attr", "device_id", + "area_id", + "area_name", ] - hass_filters = ["closest", "expand", "device_id"] + hass_filters = ["closest", "expand", "device_id", "area_id", "area_name"] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index e25cf814b2a..58b0dc19d43 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -21,7 +21,7 @@ class TraceElement: self._child_run_id: str | None = None self._error: Exception | None = None self.path: str = path - self._result: dict | None = None + self._result: dict[str, Any] | None = None self.reuse_by_child = False self._timestamp = dt_util.utcnow() diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index a77cf3c2227..e10c814389b 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -17,6 +17,8 @@ from homeassistant.loader import ( from homeassistant.util.async_ import gather_with_concurrency from homeassistant.util.json import load_json +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) TRANSLATION_LOAD_LOCK = "translation_load_lock" @@ -24,7 +26,7 @@ TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache" LOCALE_EN = "en" -def recursive_flatten(prefix: Any, data: dict) -> dict[str, Any]: +def recursive_flatten(prefix: Any, data: dict[str, Any]) -> dict[str, Any]: """Return a flattened representation of dict data.""" output = {} for key, value in data.items(): @@ -212,7 +214,7 @@ class _TranslationCache: self, language: str, category: str, - components: set, + components: set[str], ) -> list[dict[str, dict[str, Any]]]: """Load resources into the cache.""" components_to_load = components - self.loaded.setdefault(language, set()) @@ -224,7 +226,7 @@ class _TranslationCache: return [cached.get(component, {}).get(category, {}) for component in components] - async def _async_load(self, language: str, components: set) -> None: + async def _async_load(self, language: str, components: set[str]) -> None: """Populate the cache for a given set of components.""" _LOGGER.debug( "Cache miss for %s: %s", @@ -247,7 +249,7 @@ class _TranslationCache: def _build_category_cache( self, language: str, - components: set, + components: set[str], translation_strings: dict[str, dict[str, Any]], ) -> None: """Extract resources into the cache.""" diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index b5a82c3c020..29f344a6fa0 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -21,7 +21,8 @@ _PLATFORM_ALIASES = { async def _async_get_trigger_platform(hass: HomeAssistant, config: ConfigType) -> Any: - platform = config[CONF_PLATFORM] + platform_and_sub_type = config[CONF_PLATFORM].split(".") + platform = platform_and_sub_type[0] for alias, triggers in _PLATFORM_ALIASES.items(): if platform in triggers: platform = alias diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index e83a2d0edc3..2203ab240ef 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -242,10 +242,9 @@ class DataUpdateCoordinator(Generic[T]): except Exception as err: # pylint: disable=broad-except self.last_exception = err self.last_update_success = False - if log_failures: - self.logger.exception( - "Unexpected error fetching %s data: %s", self.name, err - ) + self.logger.exception( + "Unexpected error fetching %s data: %s", self.name, err + ) else: if not self.last_update_success: @@ -254,9 +253,10 @@ class DataUpdateCoordinator(Generic[T]): finally: self.logger.debug( - "Finished fetching %s data in %.3f seconds", + "Finished fetching %s data in %.3f seconds (success: %s)", self.name, monotonic() - start, + self.last_update_success, ) if not auth_failed and self._listeners and not self.hass.is_stopping: self._schedule_refresh() diff --git a/homeassistant/loader.py b/homeassistant/loader.py index a535db4bde2..e186c5d24ba 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -26,6 +26,7 @@ from awesomeversion import ( from homeassistant.generated.dhcp import DHCP from homeassistant.generated.mqtt import MQTT from homeassistant.generated.ssdp import SSDP +from homeassistant.generated.usb import USB from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF from homeassistant.util.async_ import gather_with_concurrency @@ -81,6 +82,7 @@ class Manifest(TypedDict, total=False): ssdp: list[dict[str, str]] zeroconf: list[str | dict[str, str]] dhcp: list[dict[str, str]] + usb: list[dict[str, str]] homekit: dict[str, list[str]] is_built_in: bool version: str @@ -219,6 +221,25 @@ async def async_get_dhcp(hass: HomeAssistant) -> list[dict[str, str]]: return dhcp +async def async_get_usb(hass: HomeAssistant) -> list[dict[str, str]]: + """Return cached list of usb types.""" + usb: list[dict[str, str]] = USB.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if not integration.usb: + continue + for entry in integration.usb: + usb.append( + { + "domain": integration.domain, + **{k: v for k, v in entry.items() if k != "known_devices"}, + } + ) + + return usb + + async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]: """Return cached list of homekit models.""" @@ -423,6 +444,11 @@ class Integration: """Return Integration dhcp entries.""" return self.manifest.get("dhcp") + @property + def usb(self) -> list[dict[str, str]] | None: + """Return Integration usb entries.""" + return self.manifest.get("usb") + @property def homekit(self) -> dict[str, list[str]] | None: """Return Integration homekit entries.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1b0d7a3e12d..cb6d10e4084 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.19.1 +async-upnp-client==0.20.0 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.4.0 @@ -14,26 +14,27 @@ certifi>=2020.12.5 ciso8601==2.1.3 cryptography==3.3.2 defusedxml==0.7.1 -distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.46.0 -home-assistant-frontend==20210809.0 -httpx==0.18.2 +home-assistant-frontend==20210830.0 +httpx==0.19.0 ifaddr==0.1.7 jinja2==3.0.1 paho-mqtt==1.5.1 pillow==8.2.0 pip>=8.0.3,<20.3 +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 scapy==2.4.5 -sqlalchemy==1.4.17 +sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.35.0 +zeroconf==0.36.2 pycryptodome>=3.6.6 @@ -49,12 +50,13 @@ httplib2>=0.19.0 # gRPC 1.32+ currently causes issues on ARMv7, see: # https://github.com/home-assistant/core/issues/40148 -grpcio==1.31.0 - -# Newer versions of cloud pubsub pin a higher version of grpcio. This can -# be reverted when the grpcio pin is reverted, see: +# Newer versions of some other libraries pin a higher version of grpcio, +# so those also need to be kept at an old version until the grpcio pin +# is reverted, see: # https://github.com/home-assistant/core/issues/53427 +grpcio==1.31.0 google-cloud-pubsub==2.1.0 +google-api-core<=1.31.2 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -70,3 +72,8 @@ uuid==1000000000.0.0 # Temporary constraint on pandas, to unblock 2021.7 releases # until we have fixed the wheels builds for newer versions. pandas==1.3.0 + +# regex causes segfault with version 2021.8.27 +# 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 diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index b31fc718173..69ca1d6083b 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -15,10 +15,10 @@ from homeassistant.config import get_default_config_dir from homeassistant.requirements import pip_kwargs from homeassistant.util.package import install_package, is_installed, is_virtual_env -# mypy: allow-untyped-defs, no-warn-return-any +# mypy: allow-untyped-defs, disallow-any-generics, no-warn-return-any -def run(args: list) -> int: +def run(args: list[str]) -> int: """Run a script.""" scripts = [] path = os.path.dirname(__file__) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 07bbaa22954..95bb29c4b9d 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -16,10 +16,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, PLATFORM_FORMAT, ) +from homeassistant.core import CALLBACK_TYPE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, ensure_unique_string +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT = "component" @@ -39,14 +42,17 @@ BASE_PLATFORMS = { "lock", "media_player", "notify", + "number", "remote", "scene", "select", "sensor", + "siren", "switch", "tts", "vacuum", "water_heater", + "weather", } DATA_SETUP_DONE = "setup_done" @@ -419,7 +425,7 @@ def _async_when_setup( hass.async_create_task(when_setup()) return - listeners: list[Callable] = [] + listeners: list[CALLBACK_TYPE] = [] async def _matched_event(event: core.Event) -> None: """Call the callback when we matched an event.""" @@ -440,7 +446,7 @@ def _async_when_setup( @core.callback -def async_get_loaded_integrations(hass: core.HomeAssistant) -> set: +def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: """Return the complete list of loaded integrations.""" integrations = set() for component in hass.config.components: @@ -454,7 +460,9 @@ def async_get_loaded_integrations(hass: core.HomeAssistant) -> set: @contextlib.contextmanager -def async_start_setup(hass: core.HomeAssistant, components: Iterable) -> Generator: +def async_start_setup( + hass: core.HomeAssistant, components: Iterable[str] +) -> Generator[None, None, None]: """Keep track of when setup starts and finishes.""" setup_started = hass.data.setdefault(DATA_SETUP_STARTED, {}) started = dt_util.utcnow() diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 47144f0e782..c81beddb07a 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -6,6 +6,8 @@ import math import attr +# mypy: disallow-any-generics + # 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 @@ -392,7 +394,9 @@ def color_hs_to_xy( return color_RGB_to_xy(*color_hs_to_RGB(iH, iS), Gamut) -def _match_max_scale(input_colors: tuple, output_colors: tuple) -> tuple: +def _match_max_scale( + input_colors: tuple[int, ...], output_colors: tuple[int, ...] +) -> tuple[int, ...]: """Match the maximum value of the output to the input.""" max_in = max(input_colors) max_out = max(output_colors) diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py index f4a02dbe82e..84a3faa0951 100644 --- a/homeassistant/util/volume.py +++ b/homeassistant/util/volume.py @@ -6,6 +6,8 @@ from numbers import Number from homeassistant.const import ( UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, VOLUME_GALLONS, VOLUME_LITERS, @@ -17,19 +19,31 @@ VALID_UNITS: tuple[str, ...] = ( VOLUME_MILLILITERS, VOLUME_GALLONS, VOLUME_FLUID_OUNCE, + VOLUME_CUBIC_METERS, + VOLUME_CUBIC_FEET, ) -def __liter_to_gallon(liter: float) -> float: +def liter_to_gallon(liter: float) -> float: """Convert a volume measurement in Liter to Gallon.""" return liter * 0.2642 -def __gallon_to_liter(gallon: float) -> float: +def gallon_to_liter(gallon: float) -> float: """Convert a volume measurement in Gallon to Liter.""" return gallon * 3.785 +def cubic_meter_to_cubic_feet(cubic_meter: float) -> float: + """Convert a volume measurement in cubic meter to cubic feet.""" + return cubic_meter * 35.3146667 + + +def cubic_feet_to_cubic_meter(cubic_feet: float) -> float: + """Convert a volume measurement in cubic feet to cubic meter.""" + return cubic_feet * 0.0283168466 + + def convert(volume: float, from_unit: str, to_unit: str) -> float: """Convert a temperature from one unit to another.""" if from_unit not in VALID_UNITS: @@ -45,8 +59,12 @@ def convert(volume: float, from_unit: str, to_unit: str) -> float: result: float = volume if from_unit == VOLUME_LITERS and to_unit == VOLUME_GALLONS: - result = __liter_to_gallon(volume) + result = liter_to_gallon(volume) elif from_unit == VOLUME_GALLONS and to_unit == VOLUME_LITERS: - result = __gallon_to_liter(volume) + result = gallon_to_liter(volume) + elif from_unit == VOLUME_CUBIC_METERS and to_unit == VOLUME_CUBIC_FEET: + result = cubic_meter_to_cubic_feet(volume) + elif from_unit == VOLUME_CUBIC_FEET and to_unit == VOLUME_CUBIC_METERS: + result = cubic_feet_to_cubic_meter(volume) return result diff --git a/mypy.ini b/mypy.ini index e38897bf303..02a2800a801 100644 --- a/mypy.ini +++ b/mypy.ini @@ -176,6 +176,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.amcrest.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ampio.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -704,6 +715,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.neato.*] +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.nest.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1144,6 +1166,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.uptimerobot.*] +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.vacuum.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1243,36 +1276,9 @@ no_implicit_optional = false warn_return_any = false warn_unreachable = false -[mypy-homeassistant.components.adguard.*] -ignore_errors = true - -[mypy-homeassistant.components.aemet.*] -ignore_errors = true - -[mypy-homeassistant.components.alexa.*] -ignore_errors = true - -[mypy-homeassistant.components.almond.*] -ignore_errors = true - -[mypy-homeassistant.components.amcrest.*] -ignore_errors = true - -[mypy-homeassistant.components.analytics.*] -ignore_errors = true - -[mypy-homeassistant.components.asuswrt.*] -ignore_errors = true - -[mypy-homeassistant.components.atag.*] -ignore_errors = true - [mypy-homeassistant.components.awair.*] ignore_errors = true -[mypy-homeassistant.components.azure_event_hub.*] -ignore_errors = true - [mypy-homeassistant.components.blueprint.*] ignore_errors = true @@ -1288,9 +1294,6 @@ ignore_errors = true [mypy-homeassistant.components.cloud.*] ignore_errors = true -[mypy-homeassistant.components.cloudflare.*] -ignore_errors = true - [mypy-homeassistant.components.config.*] ignore_errors = true @@ -1318,9 +1321,6 @@ ignore_errors = true [mypy-homeassistant.components.elkm1.*] ignore_errors = true -[mypy-homeassistant.components.emonitor.*] -ignore_errors = true - [mypy-homeassistant.components.enphase_envoy.*] ignore_errors = true @@ -1330,12 +1330,6 @@ ignore_errors = true [mypy-homeassistant.components.evohome.*] ignore_errors = true -[mypy-homeassistant.components.filter.*] -ignore_errors = true - -[mypy-homeassistant.components.fints.*] -ignore_errors = true - [mypy-homeassistant.components.fireservicerota.*] ignore_errors = true @@ -1363,12 +1357,6 @@ ignore_errors = true [mypy-homeassistant.components.google_assistant.*] ignore_errors = true -[mypy-homeassistant.components.google_maps.*] -ignore_errors = true - -[mypy-homeassistant.components.google_pubsub.*] -ignore_errors = true - [mypy-homeassistant.components.gpmdp.*] ignore_errors = true @@ -1378,9 +1366,6 @@ ignore_errors = true [mypy-homeassistant.components.growatt_server.*] ignore_errors = true -[mypy-homeassistant.components.gtfs.*] -ignore_errors = true - [mypy-homeassistant.components.habitica.*] ignore_errors = true @@ -1390,9 +1375,6 @@ ignore_errors = true [mypy-homeassistant.components.hassio.*] ignore_errors = true -[mypy-homeassistant.components.hdmi_cec.*] -ignore_errors = true - [mypy-homeassistant.components.here_travel_time.*] ignore_errors = true @@ -1411,9 +1393,6 @@ ignore_errors = true [mypy-homeassistant.components.homekit_controller.*] ignore_errors = true -[mypy-homeassistant.components.homematicip_cloud.*] -ignore_errors = true - [mypy-homeassistant.components.honeywell.*] ignore_errors = true @@ -1468,9 +1447,6 @@ ignore_errors = true [mypy-homeassistant.components.kulersky.*] ignore_errors = true -[mypy-homeassistant.components.lifx.*] -ignore_errors = true - [mypy-homeassistant.components.litejet.*] ignore_errors = true @@ -1489,9 +1465,6 @@ ignore_errors = true [mypy-homeassistant.components.lyric.*] ignore_errors = true -[mypy-homeassistant.components.marytts.*] -ignore_errors = true - [mypy-homeassistant.components.media_source.*] ignore_errors = true @@ -1516,30 +1489,18 @@ ignore_errors = true [mypy-homeassistant.components.mullvad.*] ignore_errors = true -[mypy-homeassistant.components.neato.*] -ignore_errors = true - [mypy-homeassistant.components.ness_alarm.*] ignore_errors = true [mypy-homeassistant.components.nest.legacy.*] ignore_errors = true -[mypy-homeassistant.components.netio.*] -ignore_errors = true - [mypy-homeassistant.components.nightscout.*] ignore_errors = true [mypy-homeassistant.components.nilu.*] ignore_errors = true -[mypy-homeassistant.components.nmap_tracker.*] -ignore_errors = true - -[mypy-homeassistant.components.norway_air.*] -ignore_errors = true - [mypy-homeassistant.components.nsw_fuel_station.*] ignore_errors = true @@ -1570,15 +1531,9 @@ ignore_errors = true [mypy-homeassistant.components.ozw.*] ignore_errors = true -[mypy-homeassistant.components.panasonic_viera.*] -ignore_errors = true - [mypy-homeassistant.components.philips_js.*] ignore_errors = true -[mypy-homeassistant.components.pilight.*] -ignore_errors = true - [mypy-homeassistant.components.ping.*] ignore_errors = true @@ -1603,15 +1558,9 @@ ignore_errors = true [mypy-homeassistant.components.profiler.*] ignore_errors = true -[mypy-homeassistant.components.proxmoxve.*] -ignore_errors = true - [mypy-homeassistant.components.rachio.*] ignore_errors = true -[mypy-homeassistant.components.reddit.*] -ignore_errors = true - [mypy-homeassistant.components.ring.*] ignore_errors = true @@ -1621,9 +1570,6 @@ ignore_errors = true [mypy-homeassistant.components.ruckus_unleashed.*] ignore_errors = true -[mypy-homeassistant.components.sabnzbd.*] -ignore_errors = true - [mypy-homeassistant.components.screenlogic.*] ignore_errors = true @@ -1633,33 +1579,18 @@ ignore_errors = true [mypy-homeassistant.components.sense.*] ignore_errors = true -[mypy-homeassistant.components.sesame.*] -ignore_errors = true - [mypy-homeassistant.components.sharkiq.*] ignore_errors = true [mypy-homeassistant.components.sma.*] ignore_errors = true -[mypy-homeassistant.components.smart_meter_texas.*] -ignore_errors = true - [mypy-homeassistant.components.smartthings.*] ignore_errors = true -[mypy-homeassistant.components.smarttub.*] -ignore_errors = true - -[mypy-homeassistant.components.smarty.*] -ignore_errors = true - [mypy-homeassistant.components.solaredge.*] ignore_errors = true -[mypy-homeassistant.components.solarlog.*] -ignore_errors = true - [mypy-homeassistant.components.somfy.*] ignore_errors = true @@ -1669,9 +1600,6 @@ ignore_errors = true [mypy-homeassistant.components.sonarr.*] ignore_errors = true -[mypy-homeassistant.components.songpal.*] -ignore_errors = true - [mypy-homeassistant.components.sonos.*] ignore_errors = true @@ -1681,15 +1609,6 @@ ignore_errors = true [mypy-homeassistant.components.stt.*] ignore_errors = true -[mypy-homeassistant.components.surepetcare.*] -ignore_errors = true - -[mypy-homeassistant.components.switchbot.*] -ignore_errors = true - -[mypy-homeassistant.components.synology_srm.*] -ignore_errors = true - [mypy-homeassistant.components.system_health.*] ignore_errors = true @@ -1708,36 +1627,18 @@ ignore_errors = true [mypy-homeassistant.components.tesla.*] ignore_errors = true -[mypy-homeassistant.components.timer.*] -ignore_errors = true - -[mypy-homeassistant.components.todoist.*] -ignore_errors = true - [mypy-homeassistant.components.toon.*] ignore_errors = true [mypy-homeassistant.components.tplink.*] ignore_errors = true -[mypy-homeassistant.components.tradfri.*] -ignore_errors = true - -[mypy-homeassistant.components.tuya.*] -ignore_errors = true - [mypy-homeassistant.components.unifi.*] ignore_errors = true -[mypy-homeassistant.components.updater.*] -ignore_errors = true - [mypy-homeassistant.components.upnp.*] ignore_errors = true -[mypy-homeassistant.components.velbus.*] -ignore_errors = true - [mypy-homeassistant.components.vera.*] ignore_errors = true @@ -1747,18 +1648,9 @@ ignore_errors = true [mypy-homeassistant.components.vizio.*] ignore_errors = true -[mypy-homeassistant.components.volumio.*] -ignore_errors = true - -[mypy-homeassistant.components.webostv.*] -ignore_errors = true - [mypy-homeassistant.components.wemo.*] ignore_errors = true -[mypy-homeassistant.components.wink.*] -ignore_errors = true - [mypy-homeassistant.components.withings.*] ignore_errors = true @@ -1771,15 +1663,9 @@ ignore_errors = true [mypy-homeassistant.components.xiaomi_miio.*] ignore_errors = true -[mypy-homeassistant.components.yamaha.*] -ignore_errors = true - [mypy-homeassistant.components.yeelight.*] ignore_errors = true -[mypy-homeassistant.components.zerproc.*] -ignore_errors = true - [mypy-homeassistant.components.zha.*] ignore_errors = true diff --git a/requirements.txt b/requirements.txt index dd445b8a7e9..70eeccdae1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2020.12.5 ciso8601==2.1.3 -httpx==0.18.2 +httpx==0.19.0 jinja2==3.0.1 PyJWT==1.7.1 cryptography==3.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 74b0e375d71..ee9f09d69ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==3.6.0 +HAP-python==4.1.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -28,9 +28,6 @@ PyEssent==0.14 # homeassistant.components.flick_electric PyFlick==0.0.2 -# homeassistant.components.github -PyGithub==1.43.8 - # homeassistant.components.mvglive PyMVGLive==1.1.4 @@ -57,8 +54,8 @@ PySocks==1.7.1 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 -# homeassistant.components.homekit -PyTurboJPEG==1.5.0 +# homeassistant.components.camera +PyTurboJPEG==1.5.2 # homeassistant.components.vicare PyViCare==1.0.0 @@ -109,7 +106,7 @@ adafruit-circuitpython-mcp230xx==2.2.2 adax==0.1.1 # homeassistant.components.androidtv -adb-shell[async]==0.3.4 +adb-shell[async]==0.4.0 # homeassistant.components.alarmdecoder adext==0.4.2 @@ -139,7 +136,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.5 +aioambient==1.3.0 # homeassistant.components.asuswrt aioasuswrt==1.3.4 @@ -160,11 +157,14 @@ aiodns==3.0.0 # homeassistant.components.eafm aioeafm==0.1.2 +# homeassistant.components.rainforest_eagle +aioeagle==1.1.0 + # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==6.0.1 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 @@ -172,6 +172,9 @@ aioflo==0.4.1 # homeassistant.components.yi aioftp==0.12.0 +# homeassistant.components.github +aiogithubapi==21.8.0 + # homeassistant.components.guardian aioguardian==1.0.8 @@ -179,7 +182,7 @@ aioguardian==1.0.8 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.6.0 +aiohomekit==0.6.2 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -198,7 +201,7 @@ aiokafka==0.6.0 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.6.9 +aiolifx==0.6.10 # homeassistant.components.lifx aiolifx_effects==0.2.2 @@ -213,7 +216,7 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.8.2 +aiomusiccast==0.9.1 # homeassistant.components.keyboard_remote aionotify==0.2.0 @@ -234,7 +237,7 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.7 +aiorecollect==1.0.8 # homeassistant.components.shelly aioshelly==0.6.4 @@ -245,6 +248,9 @@ aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tractive +aiotractive==0.5.2 + # homeassistant.components.unifi aiounifi==26 @@ -254,6 +260,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airtouch4 +airtouch4pyapi==1.0.5 + # homeassistant.components.aladdin_connect aladdin_connect==0.3 @@ -267,7 +276,7 @@ ambee==0.3.0 ambiclimate==0.2.1 # homeassistant.components.amcrest -amcrest==1.7.2 +amcrest==1.8.0 # homeassistant.components.androidtv androidtv[async]==0.0.60 @@ -276,7 +285,7 @@ androidtv[async]==0.0.60 anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anthemav -anthemav==1.1.10 +anthemav==1.2.0 # homeassistant.components.apcupsd apcaccess==0.0.13 @@ -285,7 +294,7 @@ apcaccess==0.0.13 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.3 +apprise==0.9.4 # homeassistant.components.aprs aprslib==0.6.46 @@ -308,7 +317,8 @@ asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.1 +# homeassistant.components.yeelight +async-upnp-client==0.20.0 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -362,10 +372,10 @@ beautifulsoup4==4.9.3 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.26.0 +bellows==0.27.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.19 +bimmer_connected==0.7.20 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -476,6 +486,9 @@ construct==2.10.56 # homeassistant.components.coronavirus coronavirus==1.1.1 +# homeassistant.components.utility_meter +croniter==1.0.6 + # homeassistant.components.datadog datadog==0.15.0 @@ -483,7 +496,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.4.0 +debugpy==1.4.1 # homeassistant.components.decora # decora==0.6 @@ -515,9 +528,6 @@ discogs_client==2.3.0 # homeassistant.components.discord discord.py==1.7.2 -# homeassistant.components.updater -distro==1.5.0 - # homeassistant.components.digitalloggers dlipower==0.7.165 @@ -528,7 +538,7 @@ doorbirdpy==2.1.0 dovado==0.4.1 # homeassistant.components.dsmr -dsmr_parser==0.29 +dsmr_parser==0.30 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.4 @@ -539,9 +549,6 @@ dweepy==0.3.0 # homeassistant.components.dynalite dynalite_devices==0.1.46 -# homeassistant.components.rainforest_eagle -eagle200_reader==0.2.4 - # homeassistant.components.ebusd ebusdpy==0.0.16 @@ -621,6 +628,9 @@ fitbit==0.3.1 # homeassistant.components.fixer fixerio==1.0.0a0 +# homeassistant.components.fjaraskupan +fjaraskupan==1.0.0 + # homeassistant.components.flipr flipr-api==1.4.1 @@ -634,7 +644,7 @@ fnvhash==0.1.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==2.0.0 +forecast_solar==2.1.0 # homeassistant.components.fortios fortiosapi==1.0.5 @@ -705,7 +715,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.3.5 +google-nest-sdm==0.3.6 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -741,7 +751,7 @@ guppy3==3.1.0 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==2.7.4 +ha-philipsjs==2.7.5 # homeassistant.components.habitica habitipy==0.2.0 @@ -783,7 +793,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210809.0 +home-assistant-frontend==20210830.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -828,7 +838,7 @@ iammeter==0.1.7 iaqualink==0.3.90 # homeassistant.components.watson_tts -ibm-watson==5.1.0 +ibm-watson==5.2.2 # homeassistant.components.watson_iot ibmiotf==0.3.4 @@ -854,6 +864,9 @@ influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.iotawatt +iotawattpy==0.0.8 + # homeassistant.components.iperf3 iperf3==0.1.11 @@ -938,6 +951,9 @@ lw12==0.9.2 # homeassistant.components.lyft lyft_rides==0.2 +# homeassistant.components.nmap_tracker +mac-vendor-lookup==0.1.11 + # homeassistant.components.magicseaweed magicseaweed==1.0.3 @@ -972,7 +988,7 @@ micloud==0.3 miflora==0.7.0 # homeassistant.components.mill -millheater==0.5.0 +millheater==0.5.2 # homeassistant.components.minio minio==4.0.9 @@ -1016,6 +1032,9 @@ netdata==0.2.0 # homeassistant.components.discovery netdisco==2.9.0 +# homeassistant.components.nmap_tracker +netmap==0.7.0.2 + # homeassistant.components.nam nettigo-air-monitor==1.0.0 @@ -1084,7 +1103,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.0.0 +onvif-zeep-async==1.2.0 # homeassistant.components.opengarage open-garage==0.1.5 @@ -1122,6 +1141,9 @@ orvibo==1.1.1 # homeassistant.components.ovo_energy ovoenergy==1.1.12 +# homeassistant.components.p1_monitor +p1monitor==1.0.0 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.1 @@ -1294,7 +1316,7 @@ pyW215==0.7.0 pyW800rf32==0.1 # homeassistant.components.nextbus -py_nextbusnext==0.1.4 +py_nextbusnext==0.1.5 # homeassistant.components.ads pyads==3.2.2 @@ -1339,7 +1361,7 @@ pyblackbird==0.5 # pybluez==0.22 # homeassistant.components.neato -pybotvac==0.0.21 +pybotvac==0.0.22 # homeassistant.components.nissan_leaf pycarwings2==2.11 @@ -1384,7 +1406,7 @@ pydaikin==2.4.4 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==82 +pydeconz==83 # homeassistant.components.delijn pydelijn==0.6.1 @@ -1434,9 +1456,6 @@ pyfido==2.1.1 # homeassistant.components.fireservicerota pyfireservicerota==0.0.43 -# homeassistant.components.flexit -pyflexit==0.3 - # homeassistant.components.flic pyflic==2.0.3 @@ -1459,7 +1478,7 @@ pyfreedompro==1.1.0 pyfritzhome==0.6.2 # homeassistant.components.fronius -pyfronius==0.5.3 +pyfronius==0.6.0 # homeassistant.components.ifttt pyfttt==0.3 @@ -1487,7 +1506,7 @@ pyhik==0.2.8 pyhiveapi==0.4.2 # homeassistant.components.homematic -pyhomematic==0.1.73 +pyhomematic==0.1.74 # homeassistant.components.homeworks pyhomeworks==0.0.6 @@ -1511,7 +1530,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.0.0 +pyiqvia==1.1.0 # homeassistant.components.irish_rail_transport pyirishrail==0.0.2 @@ -1562,7 +1581,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.8.0 +pylitterbot==2021.8.1 # homeassistant.components.loopenergy pyloopenergy==0.2.1 @@ -1601,7 +1620,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==2.5.2 +pymodbus==2.5.3rc1 # homeassistant.components.monoprice pymonoprice==0.3 @@ -1610,7 +1629,7 @@ pymonoprice==0.3 pymsteams==0.1.12 # homeassistant.components.myq -pymyq==3.1.2 +pymyq==3.1.3 # homeassistant.components.mysensors pymysensors==0.21.0 @@ -1649,7 +1668,7 @@ pyobihai==1.3.1 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==2.1.0 +pyopenuv==2.2.0 # homeassistant.components.opnsense pyopnsense==0.2.0 @@ -1742,6 +1761,7 @@ pysensibo==1.0.3 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 @@ -1803,7 +1823,7 @@ pysyncthru==0.7.3 pytankerkoenig==0.0.6 # homeassistant.components.tautulli -pytautulli==0.5.0 +pytautulli==21.8.1 # homeassistant.components.tfiac pytfiac==0.4 @@ -1839,7 +1859,7 @@ python-forecastio==1.4.0 # python-gammu==3.1 # homeassistant.components.gc100 -python-gc100==1.0.3a +python-gc100==1.0.3a0 # homeassistant.components.gitlab_ci python-gitlab==1.6.0 @@ -1860,7 +1880,7 @@ python-juicenet==1.0.2 # python-lirc==1.2.3 # homeassistant.components.xiaomi_miio -python-miio==0.5.6 +python-miio==0.5.7 # homeassistant.components.mpd python-mpd2==3.0.4 @@ -1871,9 +1891,6 @@ python-mystrom==1.1.2 # homeassistant.components.nest python-nest==4.1.0 -# homeassistant.components.nmap_tracker -python-nmap==0.6.1 - # homeassistant.components.ozw python-openzwave-mqtt[mqtt-client]==1.4.0 @@ -1947,8 +1964,11 @@ pytradfri[async]==7.0.6 # homeassistant.components.trafikverket_weatherstation pytrafikverket==0.1.6.2 +# homeassistant.components.usb +pyudev==0.22.0 + # homeassistant.components.uptimerobot -pyuptimerobot==0.0.5 +pyuptimerobot==21.8.2 # homeassistant.components.keyboard # pyuserinput==0.1.11 @@ -1975,7 +1995,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.6 +pywemo==0.6.7 # homeassistant.components.wilight pywilight==0.0.70 @@ -2026,7 +2046,7 @@ rfk101py==0.0.1 rflink==0.0.58 # homeassistant.components.ring -ring_doorbell==0.6.2 +ring_doorbell==0.7.1 # homeassistant.components.fleetgo ritassist==0.9.2 @@ -2093,7 +2113,7 @@ sense-hat==2.2.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.9.0 +sense_energy==0.9.2 # homeassistant.components.sentry sentry-sdk==1.3.0 @@ -2114,7 +2134,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.3 +simplisafe-python==11.0.6 # homeassistant.components.sisyphus sisyphus-control==3.0 @@ -2132,7 +2152,7 @@ sleepyq==0.8.1 slixmpp==1.7.1 # homeassistant.components.smart_meter_texas -smart-meter-texas==0.4.0 +smart-meter-texas==0.4.7 # homeassistant.components.smarthab smarthab==0.21 @@ -2161,7 +2181,7 @@ solaredge-local==0.2.0 solaredge==0.0.2 # homeassistant.components.solax -solax==0.2.6 +solax==0.2.8 # homeassistant.components.honeywell somecomfort==0.5.2 @@ -2186,7 +2206,7 @@ spotipy==2.18.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.17 +sqlalchemy==1.4.23 # homeassistant.components.srp_energy srpenergy==1.3.2 @@ -2234,7 +2254,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridge==1.1.5 +systembridge==2.0.6 # homeassistant.components.tahoma tahoma-api==0.0.16 @@ -2334,7 +2354,7 @@ uscisstatus==0.1.1 uvcclient==0.11.0 # homeassistant.components.vallox -vallox-websocket-api==2.4.0 +vallox-websocket-api==2.8.1 # homeassistant.components.venstar venstarcolortouch==0.14 @@ -2368,7 +2388,7 @@ wallbox==0.4.4 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==2.1.3 +watchdog==2.1.4 # homeassistant.components.waterfurnace waterfurnace==1.1.0 @@ -2421,28 +2441,25 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.6.3 +yeelight==0.7.4 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 # homeassistant.components.youless -youless-api==0.10 +youless-api==0.12 # homeassistant.components.media_extractor youtube_dl==2021.04.26 -# homeassistant.components.onvif -zeep[async]==4.0.0 - # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.35.0 +zeroconf==0.36.2 # homeassistant.components.zha -zha-quirks==0.0.59 +zha-quirks==0.0.60 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2454,22 +2471,22 @@ ziggo-mediabox-xl==1.1.0 zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.1 +zigpy-deconz==0.13.0 # homeassistant.components.zha -zigpy-xbee==0.13.0 +zigpy-xbee==0.14.0 # homeassistant.components.zha zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.3 +zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.36.1 +zigpy==0.37.1 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.28.0 +zwave-js-server-python==0.29.1 diff --git a/requirements_test.txt b/requirements_test.txt index aceec3229a9..86114cc02b1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,15 +2,18 @@ # make new things fail. Manually update these pins when pulling in a # new version +# types-* that have versions roughly corresponding to the packages they +# contain hints for available should be kept in sync with them + -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -codecov==2.1.11 +codecov==2.1.12 coverage==5.5 jsonpickle==1.4.1 mock-open==1.4.0 -mypy==0.902 -pre-commit==2.13.0 -pylint==2.9.5 +mypy==0.910 +pre-commit==2.14.0 +pylint==2.10.2 pipdeptree==1.0.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 @@ -25,19 +28,20 @@ responses==0.12.0 respx==0.17.0 stdlib-list==0.7.0 tqdm==4.49.0 -types-backports==0.1.2 -types-certifi==0.1.3 -types-chardet==0.1.2 +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.4 -types-emoji==1.2.1 -types-enum34==0.1.5 -types-ipaddress==0.1.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.2 -types-python-slugify==0.1.0 -types-pytz==0.1.1 -types-PyYAML==5.4.1 -types-requests==0.1.11 -types-toml==0.1.2 -types-ujson==0.1.0 +types-pkg-resources==0.1.3 +types-python-slugify==0.1.2 +types-pytz==2021.1.2 +types-PyYAML==5.4.6 +types-requests==2.25.1 +types-toml==0.1.5 +types-ujson==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67573604d73..e83fdc13817 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==3.6.0 +HAP-python==4.1.0 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -26,8 +26,8 @@ PyRMVtransport==0.3.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 -# homeassistant.components.homekit -PyTurboJPEG==1.5.0 +# homeassistant.components.camera +PyTurboJPEG==1.5.2 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 @@ -51,7 +51,7 @@ accuweather==0.2.0 adax==0.1.1 # homeassistant.components.androidtv -adb-shell[async]==0.3.4 +adb-shell[async]==0.4.0 # homeassistant.components.alarmdecoder adext==0.4.2 @@ -78,7 +78,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.5 +aioambient==1.3.0 # homeassistant.components.asuswrt aioasuswrt==1.3.4 @@ -99,11 +99,14 @@ aiodns==3.0.0 # homeassistant.components.eafm aioeafm==0.1.2 +# homeassistant.components.rainforest_eagle +aioeagle==1.1.0 + # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==6.0.1 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 @@ -115,7 +118,7 @@ aioguardian==1.0.8 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.6.0 +aiohomekit==0.6.2 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -137,7 +140,7 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.8.2 +aiomusiccast==0.9.1 # homeassistant.components.notion aionotion==3.0.2 @@ -155,7 +158,7 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.7 +aiorecollect==1.0.8 # homeassistant.components.shelly aioshelly==0.6.4 @@ -166,6 +169,9 @@ aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tractive +aiotractive==0.5.2 + # homeassistant.components.unifi aiounifi==26 @@ -175,6 +181,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airtouch4 +airtouch4pyapi==1.0.5 + # homeassistant.components.ambee ambee==0.3.0 @@ -188,7 +197,7 @@ androidtv[async]==0.0.60 apns2==0.3.0 # homeassistant.components.apprise -apprise==0.9.3 +apprise==0.9.4 # homeassistant.components.aprs aprslib==0.6.46 @@ -199,7 +208,8 @@ arcam-fmj==0.7.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.1 +# homeassistant.components.yeelight +async-upnp-client==0.20.0 # homeassistant.components.aurora auroranoaa==0.0.2 @@ -217,10 +227,10 @@ azure-eventhub==5.5.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.26.0 +bellows==0.27.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.19 +bimmer_connected==0.7.20 # homeassistant.components.blebox blebox_uniapi==1.3.3 @@ -272,6 +282,9 @@ construct==2.10.56 # homeassistant.components.coronavirus coronavirus==1.1.1 +# homeassistant.components.utility_meter +croniter==1.0.6 + # homeassistant.components.datadog datadog==0.15.0 @@ -279,7 +292,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.4.0 +debugpy==1.4.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -296,14 +309,11 @@ devolo-home-control-api==0.17.4 # homeassistant.components.directv directv==0.4.0 -# homeassistant.components.updater -distro==1.5.0 - # homeassistant.components.doorbird doorbirdpy==2.1.0 # homeassistant.components.dsmr -dsmr_parser==0.29 +dsmr_parser==0.30 # homeassistant.components.dynalite dynalite_devices==0.1.46 @@ -338,6 +348,9 @@ faadelays==0.0.7 # homeassistant.components.feedreader feedparser==6.0.2 +# homeassistant.components.fjaraskupan +fjaraskupan==1.0.0 + # homeassistant.components.flipr flipr-api==1.4.1 @@ -348,7 +361,7 @@ fnvhash==0.1.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==2.0.0 +forecast_solar==2.1.0 # homeassistant.components.freebox freebox-api==0.0.10 @@ -401,7 +414,7 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.3.5 +google-nest-sdm==0.3.6 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -419,7 +432,7 @@ guppy3==3.1.0 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==2.7.4 +ha-philipsjs==2.7.5 # homeassistant.components.habitica habitipy==0.2.0 @@ -449,7 +462,7 @@ hole==0.5.1 holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210809.0 +home-assistant-frontend==20210830.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -491,6 +504,9 @@ influxdb-client==1.14.0 # homeassistant.components.influxdb influxdb==5.2.3 +# homeassistant.components.iotawatt +iotawattpy==0.0.8 + # homeassistant.components.gogogate2 ismartgate==4.0.0 @@ -524,6 +540,9 @@ logi_circle==0.2.2 # homeassistant.components.luftdaten luftdaten==0.6.5 +# homeassistant.components.nmap_tracker +mac-vendor-lookup==0.1.11 + # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -543,7 +562,7 @@ mficlient==0.3.0 micloud==0.3 # homeassistant.components.mill -millheater==0.5.0 +millheater==0.5.2 # homeassistant.components.minio minio==4.0.9 @@ -572,6 +591,9 @@ nessclient==0.9.15 # homeassistant.components.discovery netdisco==2.9.0 +# homeassistant.components.nmap_tracker +netmap==0.7.0.2 + # homeassistant.components.nam nettigo-air-monitor==1.0.0 @@ -613,7 +635,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.0.0 +onvif-zeep-async==1.2.0 # homeassistant.components.openerz openerz-api==0.1.0 @@ -621,6 +643,9 @@ openerz-api==0.1.0 # homeassistant.components.ovo_energy ovoenergy==1.1.12 +# homeassistant.components.p1_monitor +p1monitor==1.0.0 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.1 @@ -727,7 +752,7 @@ pyRFXtrx==0.27.0 pyTibber==0.19.0 # homeassistant.components.nextbus -py_nextbusnext==0.1.4 +py_nextbusnext==0.1.5 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 @@ -757,7 +782,7 @@ pyatv==0.8.2 pyblackbird==0.5 # homeassistant.components.neato -pybotvac==0.0.21 +pybotvac==0.0.22 # homeassistant.components.cloudflare pycfdns==1.2.1 @@ -778,7 +803,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.4.4 # homeassistant.components.deconz -pydeconz==82 +pydeconz==83 # homeassistant.components.dexcom pydexcom==0.2.0 @@ -836,7 +861,7 @@ pyheos==0.7.2 pyhiveapi==0.4.2 # homeassistant.components.homematic -pyhomematic==0.1.73 +pyhomematic==0.1.74 # homeassistant.components.ialarm pyialarm==1.9.0 @@ -854,7 +879,7 @@ pyipma==2.0.5 pyipp==0.11.0 # homeassistant.components.iqvia -pyiqvia==1.0.0 +pyiqvia==1.1.0 # homeassistant.components.isy994 pyisy==3.0.0 @@ -884,7 +909,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.8.0 +pylitterbot==2021.8.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.11.0 @@ -911,17 +936,20 @@ pymfy==0.11.0 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==2.5.2 +pymodbus==2.5.3rc1 # homeassistant.components.monoprice pymonoprice==0.3 # homeassistant.components.myq -pymyq==3.1.2 +pymyq==3.1.3 # homeassistant.components.mysensors pymysensors==0.21.0 +# homeassistant.components.nanoleaf +pynanoleaf==0.1.0 + # homeassistant.components.nuki pynuki==1.4.1 @@ -938,7 +966,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.openuv -pyopenuv==2.1.0 +pyopenuv==2.2.0 # homeassistant.components.opnsense pyopnsense==0.2.0 @@ -992,6 +1020,7 @@ pyruckus==0.12 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 @@ -1038,7 +1067,7 @@ python-izone==1.1.6 python-juicenet==1.0.2 # homeassistant.components.xiaomi_miio -python-miio==0.5.6 +python-miio==0.5.7 # homeassistant.components.nest python-nest==4.1.0 @@ -1076,6 +1105,12 @@ pytraccar==0.9.0 # homeassistant.components.tradfri pytradfri[async]==7.0.6 +# homeassistant.components.usb +pyudev==0.22.0 + +# homeassistant.components.uptimerobot +pyuptimerobot==21.8.2 + # homeassistant.components.vera pyvera==0.3.13 @@ -1092,7 +1127,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.6 +pywemo==0.6.7 # homeassistant.components.wilight pywilight==0.0.70 @@ -1116,7 +1151,7 @@ restrictedpython==5.1 rflink==0.0.58 # homeassistant.components.ring -ring_doorbell==0.6.2 +ring_doorbell==0.7.1 # homeassistant.components.roku rokuecp==0.8.1 @@ -1147,7 +1182,7 @@ screenlogicpy==0.4.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.9.0 +sense_energy==0.9.2 # homeassistant.components.sentry sentry-sdk==1.3.0 @@ -1159,7 +1194,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.3 +simplisafe-python==11.0.6 # homeassistant.components.slack slackclient==2.5.0 @@ -1168,7 +1203,7 @@ slackclient==2.5.0 sleepyq==0.8.1 # homeassistant.components.smart_meter_texas -smart-meter-texas==0.4.0 +smart-meter-texas==0.4.7 # homeassistant.components.smarthab smarthab==0.21 @@ -1205,7 +1240,7 @@ spotipy==2.18.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.17 +sqlalchemy==1.4.23 # homeassistant.components.srp_energy srpenergy==1.3.2 @@ -1232,7 +1267,7 @@ sunwatcher==0.2.1 surepy==0.7.0 # homeassistant.components.system_bridge -systembridge==1.1.5 +systembridge==2.0.6 # homeassistant.components.tellduslive tellduslive==0.10.11 @@ -1264,6 +1299,9 @@ twilio==6.32.0 # homeassistant.components.twinkly twinkly-client==0.0.2 +# homeassistant.components.rainforest_eagle +uEagle==0.0.2 + # homeassistant.components.upb upb_lib==0.4.12 @@ -1297,7 +1335,7 @@ wakeonlan==2.0.1 wallbox==0.4.4 # homeassistant.components.folder_watcher -watchdog==2.1.3 +watchdog==2.1.4 # homeassistant.components.wiffi wiffi==1.0.1 @@ -1332,37 +1370,34 @@ yalesmartalarmclient==0.3.4 yalexs==1.1.13 # homeassistant.components.yeelight -yeelight==0.6.3 +yeelight==0.7.4 # homeassistant.components.youless -youless-api==0.10 - -# homeassistant.components.onvif -zeep[async]==4.0.0 +youless-api==0.12 # homeassistant.components.zeroconf -zeroconf==0.35.0 +zeroconf==0.36.2 # homeassistant.components.zha -zha-quirks==0.0.59 +zha-quirks==0.0.60 # homeassistant.components.zha zigpy-cc==0.5.2 # homeassistant.components.zha -zigpy-deconz==0.12.1 +zigpy-deconz==0.13.0 # homeassistant.components.zha -zigpy-xbee==0.13.0 +zigpy-xbee==0.14.0 # homeassistant.components.zha zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.3 +zigpy-znp==0.5.4 # homeassistant.components.zha -zigpy==0.36.1 +zigpy==0.37.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.28.0 +zwave-js-server-python==0.29.1 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 795a4c3bcd6..e89785c25a8 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -7,10 +7,10 @@ flake8-comprehensions==3.5.0 flake8-docstrings==1.6.0 flake8-noqa==1.1.0 flake8==3.9.2 -isort==5.8.0 +isort==5.9.3 mccabe==0.6.1 pycodestyle==2.7.0 pydocstyle==6.0.0 pyflakes==2.3.1 -pyupgrade==2.23.0 +pyupgrade==2.23.3 yamllint==1.26.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 934ea9be90c..f535958412d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -71,12 +71,13 @@ httplib2>=0.19.0 # gRPC 1.32+ currently causes issues on ARMv7, see: # https://github.com/home-assistant/core/issues/40148 -grpcio==1.31.0 - -# Newer versions of cloud pubsub pin a higher version of grpcio. This can -# be reverted when the grpcio pin is reverted, see: +# Newer versions of some other libraries pin a higher version of grpcio, +# so those also need to be kept at an old version until the grpcio pin +# is reverted, see: # https://github.com/home-assistant/core/issues/53427 +grpcio==1.31.0 google-cloud-pubsub==2.1.0 +google-api-core<=1.31.2 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -92,6 +93,11 @@ uuid==1000000000.0.0 # Temporary constraint on pandas, to unblock 2021.7 releases # until we have fixed the wheels builds for newer versions. pandas==1.3.0 + +# regex causes segfault with version 2021.8.27 +# 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 """ IGNORE_PRE_COMMIT_HOOK_ID = ( diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index f9a1aa54c69..d4935196cc7 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -18,22 +18,25 @@ from . import ( services, ssdp, translations, + usb, zeroconf, ) from .model import Config, Integration INTEGRATION_PLUGINS = [ - json, codeowners, config_flow, dependencies, + dhcp, + json, manifest, mqtt, + requirements, services, ssdp, translations, + usb, zeroconf, - dhcp, ] HASS_PLUGINS = [ coverage, @@ -101,9 +104,6 @@ def main(): plugins = [*INTEGRATION_PLUGINS] - if config.requirements: - plugins.append(requirements) - if config.specific_integrations: integrations = {} @@ -120,7 +120,11 @@ def main(): try: start = monotonic() print(f"Validating {plugin.__name__.split('.')[-1]}...", end="", flush=True) - if plugin is requirements and not config.specific_integrations: + if ( + plugin is requirements + and config.requirements + and not config.specific_integrations + ): print() plugin.validate(integrations, config) print(f" done in {monotonic() - start:.2f}s") diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index e4d1be7bc46..87e9bea6291 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -29,31 +29,6 @@ def validate_integration(config: Config, integration: Integration): "config_flow", "Config flows need to be defined in the file config_flow.py", ) - if integration.manifest.get("homekit"): - integration.add_error( - "config_flow", - "HomeKit information in a manifest requires a config flow to exist", - ) - if integration.manifest.get("mqtt"): - integration.add_error( - "config_flow", - "MQTT information in a manifest requires a config flow to exist", - ) - if integration.manifest.get("ssdp"): - integration.add_error( - "config_flow", - "SSDP information in a manifest requires a config flow to exist", - ) - if integration.manifest.get("zeroconf"): - integration.add_error( - "config_flow", - "Zeroconf information in a manifest requires a config flow to exist", - ) - if integration.manifest.get("dhcp"): - integration.add_error( - "config_flow", - "DHCP information in a manifest requires a config flow to exist", - ) return config_flow = config_flow_file.read_text() @@ -66,6 +41,7 @@ def validate_integration(config: Config, integration: Integration): or "async_step_ssdp" in config_flow or "async_step_zeroconf" in config_flow or "async_step_dhcp" in config_flow + or "async_step_usb" in config_flow ) if not needs_unique_id: @@ -98,17 +74,7 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: - continue - - if not ( - integration.manifest.get("config_flow") - or integration.manifest.get("homekit") - or integration.manifest.get("mqtt") - or integration.manifest.get("ssdp") - or integration.manifest.get("zeroconf") - or integration.manifest.get("dhcp") - ): + if not integration.manifest or not integration.config_flow: continue validate_integration(config, integration) diff --git a/script/hassfest/dhcp.py b/script/hassfest/dhcp.py index a3abe80063e..c746c64e46f 100644 --- a/script/hassfest/dhcp.py +++ b/script/hassfest/dhcp.py @@ -24,7 +24,7 @@ def generate_and_validate(integrations: list[dict[str, str]]): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if not integration.manifest or not integration.config_flow: continue match_types = integration.manifest.get("dhcp", []) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 797729542f4..abade24dbf9 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -205,6 +205,18 @@ MANIFEST_SCHEMA = vol.Schema( } ) ], + vol.Optional("usb"): [ + vol.Schema( + { + vol.Optional("vid"): vol.All(str, verify_uppercase), + vol.Optional("pid"): vol.All(str, verify_uppercase), + vol.Optional("serial_number"): vol.All(str, verify_lowercase), + vol.Optional("manufacturer"): vol.All(str, verify_lowercase), + vol.Optional("description"): vol.All(str, verify_lowercase), + vol.Optional("known_devices"): [str], + } + ) + ], vol.Required("documentation"): vol.All( vol.Url(), documentation_url # pylint: disable=no-value-for-parameter ), diff --git a/script/hassfest/model.py b/script/hassfest/model.py index b20df6ea42f..69810686cc1 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -96,6 +96,11 @@ class Integration: """Return quality scale of the integration.""" return self.manifest.get("quality_scale") + @property + def config_flow(self) -> str: + """Return if the integration has a config flow.""" + return self.manifest.get("config_flow") + @property def requirements(self) -> list[str]: """List of requirements.""" diff --git a/script/hassfest/mqtt.py b/script/hassfest/mqtt.py index 718df4ac827..f325518d7b9 100644 --- a/script/hassfest/mqtt.py +++ b/script/hassfest/mqtt.py @@ -26,7 +26,7 @@ def generate_and_validate(integrations: dict[str, Integration]): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if not integration.manifest or not integration.config_flow: continue mqtt = integration.manifest.get("mqtt") diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 8ff72c332da..0026be479a4 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -14,22 +14,12 @@ from .model import Config, Integration # remove your component from this list to enable type checks. # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ - "homeassistant.components.adguard.*", - "homeassistant.components.aemet.*", - "homeassistant.components.alexa.*", - "homeassistant.components.almond.*", - "homeassistant.components.amcrest.*", - "homeassistant.components.analytics.*", - "homeassistant.components.asuswrt.*", - "homeassistant.components.atag.*", "homeassistant.components.awair.*", - "homeassistant.components.azure_event_hub.*", "homeassistant.components.blueprint.*", "homeassistant.components.bmw_connected_drive.*", "homeassistant.components.cert_expiry.*", "homeassistant.components.climacell.*", "homeassistant.components.cloud.*", - "homeassistant.components.cloudflare.*", "homeassistant.components.config.*", "homeassistant.components.conversation.*", "homeassistant.components.deconz.*", @@ -39,12 +29,9 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.directv.*", "homeassistant.components.doorbird.*", "homeassistant.components.elkm1.*", - "homeassistant.components.emonitor.*", "homeassistant.components.enphase_envoy.*", "homeassistant.components.entur_public_transport.*", "homeassistant.components.evohome.*", - "homeassistant.components.filter.*", - "homeassistant.components.fints.*", "homeassistant.components.fireservicerota.*", "homeassistant.components.firmata.*", "homeassistant.components.flo.*", @@ -54,23 +41,18 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.geniushub.*", "homeassistant.components.glances.*", "homeassistant.components.google_assistant.*", - "homeassistant.components.google_maps.*", - "homeassistant.components.google_pubsub.*", "homeassistant.components.gpmdp.*", "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", - "homeassistant.components.gtfs.*", "homeassistant.components.habitica.*", "homeassistant.components.harmony.*", "homeassistant.components.hassio.*", - "homeassistant.components.hdmi_cec.*", "homeassistant.components.here_travel_time.*", "homeassistant.components.hisense_aehw4a1.*", "homeassistant.components.home_connect.*", "homeassistant.components.home_plus_control.*", "homeassistant.components.homekit.*", "homeassistant.components.homekit_controller.*", - "homeassistant.components.homematicip_cloud.*", "homeassistant.components.honeywell.*", "homeassistant.components.humidifier.*", "homeassistant.components.iaqualink.*", @@ -89,14 +71,12 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.konnected.*", "homeassistant.components.kostal_plenticore.*", "homeassistant.components.kulersky.*", - "homeassistant.components.lifx.*", "homeassistant.components.litejet.*", "homeassistant.components.litterrobot.*", "homeassistant.components.lovelace.*", "homeassistant.components.luftdaten.*", "homeassistant.components.lutron_caseta.*", "homeassistant.components.lyric.*", - "homeassistant.components.marytts.*", "homeassistant.components.media_source.*", "homeassistant.components.melcloud.*", "homeassistant.components.meteo_france.*", @@ -105,14 +85,10 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.mobile_app.*", "homeassistant.components.motion_blinds.*", "homeassistant.components.mullvad.*", - "homeassistant.components.neato.*", "homeassistant.components.ness_alarm.*", "homeassistant.components.nest.legacy.*", - "homeassistant.components.netio.*", "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", - "homeassistant.components.nmap_tracker.*", - "homeassistant.components.norway_air.*", "homeassistant.components.nsw_fuel_station.*", "homeassistant.components.nuki.*", "homeassistant.components.nws.*", @@ -123,9 +99,7 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.onvif.*", "homeassistant.components.ovo_energy.*", "homeassistant.components.ozw.*", - "homeassistant.components.panasonic_viera.*", "homeassistant.components.philips_js.*", - "homeassistant.components.pilight.*", "homeassistant.components.ping.*", "homeassistant.components.pioneer.*", "homeassistant.components.plaato.*", @@ -134,65 +108,42 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.plum_lightpad.*", "homeassistant.components.point.*", "homeassistant.components.profiler.*", - "homeassistant.components.proxmoxve.*", "homeassistant.components.rachio.*", - "homeassistant.components.reddit.*", "homeassistant.components.ring.*", "homeassistant.components.rpi_power.*", "homeassistant.components.ruckus_unleashed.*", - "homeassistant.components.sabnzbd.*", "homeassistant.components.screenlogic.*", "homeassistant.components.search.*", "homeassistant.components.sense.*", - "homeassistant.components.sesame.*", "homeassistant.components.sharkiq.*", "homeassistant.components.sma.*", - "homeassistant.components.smart_meter_texas.*", "homeassistant.components.smartthings.*", - "homeassistant.components.smarttub.*", - "homeassistant.components.smarty.*", "homeassistant.components.solaredge.*", - "homeassistant.components.solarlog.*", "homeassistant.components.somfy.*", "homeassistant.components.somfy_mylink.*", "homeassistant.components.sonarr.*", - "homeassistant.components.songpal.*", "homeassistant.components.sonos.*", "homeassistant.components.spotify.*", "homeassistant.components.stt.*", - "homeassistant.components.surepetcare.*", - "homeassistant.components.switchbot.*", - "homeassistant.components.synology_srm.*", "homeassistant.components.system_health.*", "homeassistant.components.system_log.*", "homeassistant.components.tado.*", "homeassistant.components.telegram_bot.*", "homeassistant.components.template.*", "homeassistant.components.tesla.*", - "homeassistant.components.timer.*", - "homeassistant.components.todoist.*", "homeassistant.components.toon.*", "homeassistant.components.tplink.*", - "homeassistant.components.tradfri.*", - "homeassistant.components.tuya.*", "homeassistant.components.unifi.*", - "homeassistant.components.updater.*", "homeassistant.components.upnp.*", - "homeassistant.components.velbus.*", "homeassistant.components.vera.*", "homeassistant.components.verisure.*", "homeassistant.components.vizio.*", - "homeassistant.components.volumio.*", - "homeassistant.components.webostv.*", "homeassistant.components.wemo.*", - "homeassistant.components.wink.*", "homeassistant.components.withings.*", "homeassistant.components.xbox.*", "homeassistant.components.xiaomi_aqara.*", "homeassistant.components.xiaomi_miio.*", - "homeassistant.components.yamaha.*", "homeassistant.components.yeelight.*", - "homeassistant.components.zerproc.*", "homeassistant.components.zha.*", "homeassistant.components.zwave.*", ] @@ -218,7 +169,9 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { } # This is basically the list of checks which is enabled for "strict=true". -# But "strict=true" is applied globally, so we need to list all checks manually. +# "strict=false" in config files does not turn strict settings off if they've been +# set in a more general section (it instead means as if strict was not specified at +# all), so we need to list all checks manually to be able to flip them wholesale. STRICT_SETTINGS: Final[list[str]] = [ "check_untyped_defs", "disallow_incomplete_defs", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 5927824b21f..f72562f7f2f 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -9,6 +9,7 @@ import re import subprocess import sys +from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from stdlib_list import stdlib_list from tqdm import tqdm @@ -61,6 +62,12 @@ def normalize_package_name(requirement: str) -> str: def validate(integrations: dict[str, Integration], config: Config): """Handle requirements for integrations.""" + # Check if we are doing format-only validation. + if not config.requirements: + for integration in integrations.values(): + validate_requirements_format(integration) + return + ensure_cache() # check for incompatible requirements @@ -74,8 +81,45 @@ def validate(integrations: dict[str, Integration], config: Config): validate_requirements(integration) +def validate_requirements_format(integration: Integration) -> bool: + """Validate requirements format. + + Returns if valid. + """ + start_errors = len(integration.errors) + + for req in integration.requirements: + if " " in req: + integration.add_error( + "requirements", + f'Requirement "{req}" contains a space', + ) + continue + + pkg, sep, version = req.partition("==") + + if not sep and integration.core: + integration.add_error( + "requirements", + f'Requirement {req} need to be pinned "==".', + ) + continue + + if AwesomeVersion(version).strategy == AwesomeVersionStrategy.UNKNOWN: + integration.add_error( + "requirements", + f"Unable to parse package version ({version}) for {pkg}.", + ) + continue + + return len(integration.errors) == start_errors + + def validate_requirements(integration: Integration): """Validate requirements.""" + if not validate_requirements_format(integration): + return + # Some integrations have not been fixed yet so are allowed to have violations. if integration.domain in IGNORE_VIOLATIONS: return diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index c71d5432adf..0611f9a2225 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -31,7 +31,7 @@ def generate_and_validate(integrations: dict[str, Integration]): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if not integration.manifest or not integration.config_flow: continue ssdp = integration.manifest.get("ssdp") diff --git a/script/hassfest/usb.py b/script/hassfest/usb.py new file mode 100644 index 00000000000..6377fdcb8af --- /dev/null +++ b/script/hassfest/usb.py @@ -0,0 +1,69 @@ +"""Generate usb file.""" +from __future__ import annotations + +import json + +from .model import Config, Integration + +BASE = """ +\"\"\"Automatically generated by hassfest. + +To update, run python3 -m script.hassfest +\"\"\" + +# fmt: off + +USB = {} +""".strip() + + +def generate_and_validate(integrations: list[dict[str, str]]) -> str: + """Validate and generate usb data.""" + match_list = [] + + for domain in sorted(integrations): + integration = integrations[domain] + + if not integration.manifest or not integration.config_flow: + continue + + match_types = integration.manifest.get("usb", []) + + if not match_types: + continue + + for entry in match_types: + match_list.append( + { + "domain": domain, + **{k: v for k, v in entry.items() if k != "known_devices"}, + } + ) + + return BASE.format(json.dumps(match_list, indent=4)) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate usb file.""" + usb_path = config.root / "homeassistant/generated/usb.py" + config.cache["usb"] = content = generate_and_validate(integrations) + + if config.specific_integrations: + return + + with open(str(usb_path)) as fp: + current = fp.read().strip() + if current != content: + config.add_error( + "usb", + "File usb.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + return + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate usb file.""" + usb_path = config.root / "homeassistant/generated/usb.py" + with open(str(usb_path), "w") as fp: + fp.write(f"{config.cache['usb']}\n") diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 907c6aaceff..4ce4896952e 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -28,7 +28,7 @@ def generate_and_validate(integrations: dict[str, Integration]): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if not integration.manifest or not integration.config_flow: continue service_types = integration.manifest.get("zeroconf", []) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index e72d9eb7679..c6a6ec6b629 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -15,7 +15,7 @@ async def test_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {} + assert result["errors"] is None with patch( "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index f597ef609ea..8b1bdc93749 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -1,8 +1,6 @@ """The NEW_NAME integration.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -13,6 +11,7 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, ) +from homeassistant.helpers.typing import ConfigType from . import api, config_flow from .const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN @@ -34,7 +33,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["light"] -async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NEW_NAME component.""" hass.data[DOMAIN] = {} diff --git a/script/scaffold/templates/device_action/integration/device_action.py b/script/scaffold/templates/device_action/integration/device_action.py index 720e472851c..5eb5249211b 100644 --- a/script/scaffold/templates/device_action/integration/device_action.py +++ b/script/scaffold/templates/device_action/integration/device_action.py @@ -29,7 +29,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) -async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device actions for NEW_NAME devices.""" registry = await entity_registry.async_get_registry(hass) actions = [] diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index e070bc43f57..16dc43f8d59 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for NEW_NAME.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -32,7 +34,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """List device triggers for NEW_NAME devices.""" registry = await entity_registry.async_get_registry(hass) triggers = [] diff --git a/script/scaffold/templates/integration/integration/__init__.py b/script/scaffold/templates/integration/integration/__init__.py index c1f34d5f5b1..e30cd400bf2 100644 --- a/script/scaffold/templates/integration/integration/__init__.py +++ b/script/scaffold/templates/integration/integration/__init__.py @@ -1,17 +1,16 @@ """The NEW_NAME integration.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN CONFIG_SCHEMA = vol.Schema({vol.Optional(DOMAIN): {}}, extra=vol.ALLOW_EXTRA) -async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NEW_NAME integration.""" return True diff --git a/setup.py b/setup.py index db4e8a54d72..302eadbfcf6 100755 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ REQUIRES = [ "bcrypt==3.1.7", "certifi>=2020.12.5", "ciso8601==2.1.3", - "httpx==0.18.2", + "httpx==0.19.0", "jinja2==3.0.1", "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. diff --git a/tests/common.py b/tests/common.py index 5de58a08472..3d5e28be514 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,10 +29,9 @@ from homeassistant.auth import ( providers as auth_providers, ) from homeassistant.auth.permissions import system_policies -from homeassistant.components import recorder +from homeassistant.components import device_automation, recorder from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, - _async_get_device_automations as async_get_device_automations, ) from homeassistant.components.mqtt.models import ReceiveMessage from homeassistant.config import async_process_component_config @@ -69,6 +68,16 @@ CLIENT_ID = "https://example.com/app" CLIENT_REDIRECT_URI = "https://example.com/app/callback" +async def async_get_device_automations( + hass: HomeAssistant, automation_type: str, device_id: str +) -> Any: + """Get a device automation for a single device id.""" + automations = await device_automation.async_get_device_automations( + hass, automation_type, [device_id] + ) + return automations.get(device_id) + + def threadsafe_callback_factory(func): """Create threadsafe functions out of callbacks. diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index c566702a5b4..cd17c692176 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -7,10 +7,13 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, 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, @@ -38,6 +41,7 @@ async def test_sensor(hass, aioclient_mock): assert state.state == "23" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AQI entry = registry.async_get("sensor.home_caqi") assert entry @@ -63,7 +67,7 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM1 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT entry = registry.async_get("sensor.home_pm1") @@ -78,7 +82,7 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT entry = registry.async_get("sensor.home_pm2_5") @@ -93,7 +97,7 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT entry = registry.async_get("sensor.home_pm10") diff --git a/tests/components/airtouch4/__init__.py b/tests/components/airtouch4/__init__.py new file mode 100644 index 00000000000..cc267ee41d1 --- /dev/null +++ b/tests/components/airtouch4/__init__.py @@ -0,0 +1 @@ +"""Tests for the AirTouch4 integration.""" diff --git a/tests/components/airtouch4/test_config_flow.py b/tests/components/airtouch4/test_config_flow.py new file mode 100644 index 00000000000..a98b24ef88d --- /dev/null +++ b/tests/components/airtouch4/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the AirTouch 4 config flow.""" +from unittest.mock import AsyncMock, Mock, patch + +from airtouch4pyapi.airtouch import AirTouch, AirTouchAc, AirTouchGroup, AirTouchStatus + +from homeassistant import config_entries +from homeassistant.components.airtouch4.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["errors"] is None + mock_ac = AirTouchAc() + mock_groups = AirTouchGroup() + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.Status = AirTouchStatus.OK + mock_airtouch.GetAcs = Mock(return_value=[mock_ac]) + mock_airtouch.GetGroups = Mock(return_value=[mock_groups]) + + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ), patch( + "homeassistant.components.airtouch4.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "0.0.0.1" + assert result2["data"] == { + "host": "0.0.0.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_timeout(hass): + """Test we handle a connection timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.status = AirTouchStatus.CONNECTION_INTERRUPTED + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_library_error_message(hass): + """Test we handle an unknown error message from the library.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.status = AirTouchStatus.ERROR + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_connection_refused(hass): + """Test we handle a connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.status = AirTouchStatus.NOT_CONNECTED + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_no_units(hass): + """Test we handle no units found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_ac = AirTouchAc() + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.Status = AirTouchStatus.OK + mock_airtouch.GetAcs = Mock(return_value=[mock_ac]) + mock_airtouch.GetGroups = Mock(return_value=[]) + + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "no_units"} diff --git a/tests/components/alarm_control_panel/common.py b/tests/components/alarm_control_panel/common.py index fa50a1aab41..e46bac2fc1f 100644 --- a/tests/components/alarm_control_panel/common.py +++ b/tests/components/alarm_control_panel/common.py @@ -12,6 +12,7 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, ) @@ -61,6 +62,19 @@ async def async_alarm_arm_night(hass, code=None, entity_id=ENTITY_MATCH_ALL): await hass.services.async_call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data, blocking=True) +async def async_alarm_arm_vacation(hass, code=None, entity_id=ENTITY_MATCH_ALL): + """Send the alarm the command for vacation mode.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_VACATION, data, blocking=True + ) + + async def async_alarm_trigger(hass, code=None, entity_id=ENTITY_MATCH_ALL): """Send the alarm the command for disarm.""" data = {} diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index 892abaa9650..b5a3d90fbdb 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -82,7 +82,7 @@ async def test_abort_if_existing_entry(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" - result = await flow.async_step_import() + result = await flow.async_step_import({}) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py index b8389d1903f..2c1f3e26b54 100644 --- a/tests/components/arlo/test_sensor.py +++ b/tests/components/arlo/test_sensor.py @@ -5,8 +5,10 @@ from unittest.mock import patch import pytest from homeassistant.components.arlo import DATA_ARLO, sensor as arlo +from homeassistant.components.arlo.sensor import SENSOR_TYPES from homeassistant.const import ( ATTR_ATTRIBUTION, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, @@ -17,49 +19,55 @@ def _get_named_tuple(input_dict): return namedtuple("Struct", input_dict.keys())(*input_dict.values()) -def _get_sensor(name="Last", sensor_type="last_capture", data=None): +def _get_sensor(hass, name="Last", sensor_type="last_capture", data=None): if data is None: data = {} - return arlo.ArloSensor(name, data, sensor_type) + sensor_entry = next( + sensor_entry for sensor_entry in SENSOR_TYPES if sensor_entry.key == sensor_type + ) + sensor_entry.name = name + sensor = arlo.ArloSensor(data, sensor_entry) + sensor.hass = hass + return sensor @pytest.fixture() -def default_sensor(): +def default_sensor(hass): """Create an ArloSensor with default values.""" - return _get_sensor() + return _get_sensor(hass) @pytest.fixture() -def battery_sensor(): +def battery_sensor(hass): """Create an ArloSensor with battery data.""" data = _get_named_tuple({"battery_level": 50}) - return _get_sensor("Battery Level", "battery_level", data) + return _get_sensor(hass, "Battery Level", "battery_level", data) @pytest.fixture() -def temperature_sensor(): +def temperature_sensor(hass): """Create a temperature ArloSensor.""" - return _get_sensor("Temperature", "temperature") + return _get_sensor(hass, "Temperature", "temperature") @pytest.fixture() -def humidity_sensor(): +def humidity_sensor(hass): """Create a humidity ArloSensor.""" - return _get_sensor("Humidity", "humidity") + return _get_sensor(hass, "Humidity", "humidity") @pytest.fixture() -def cameras_sensor(): +def cameras_sensor(hass): """Create a total cameras ArloSensor.""" data = _get_named_tuple({"cameras": [0, 0]}) - return _get_sensor("Arlo Cameras", "total_cameras", data) + return _get_sensor(hass, "Arlo Cameras", "total_cameras", data) @pytest.fixture() -def captured_sensor(): +def captured_sensor(hass): """Create a captured today ArloSensor.""" data = _get_named_tuple({"captured_today": [0, 0, 0, 0, 0]}) - return _get_sensor("Captured Today", "captured_today", data) + return _get_sensor(hass, "Captured Today", "captured_today", data) class PlatformSetupFixture: @@ -82,14 +90,6 @@ def platform_setup(): return PlatformSetupFixture() -@pytest.fixture() -def sensor_with_hass_data(default_sensor, hass): - """Create a sensor with async_dispatcher_connected mocked.""" - hass.data = {} - default_sensor.hass = hass - return default_sensor - - @pytest.fixture() def mock_dispatch(): """Mock the dispatcher connect method.""" @@ -139,14 +139,14 @@ def test_sensor_name(default_sensor): assert default_sensor.name == "Last" -async def test_async_added_to_hass(sensor_with_hass_data, mock_dispatch): +async def test_async_added_to_hass(default_sensor, mock_dispatch): """Test dispatcher called when added.""" - await sensor_with_hass_data.async_added_to_hass() + await default_sensor.async_added_to_hass() assert len(mock_dispatch.mock_calls) == 1 kall = mock_dispatch.call_args args, kwargs = kall assert len(args) == 3 - assert args[0] == sensor_with_hass_data.hass + assert args[0] == default_sensor.hass assert args[1] == "arlo_update" assert not kwargs @@ -156,14 +156,14 @@ def test_sensor_state_default(default_sensor): assert default_sensor.state is None -def test_sensor_icon_battery(battery_sensor): - """Test the battery icon.""" - assert battery_sensor.icon == "mdi:battery-50" +def test_sensor_device_class__battery(battery_sensor): + """Test the battery device_class.""" + assert battery_sensor.device_class == DEVICE_CLASS_BATTERY -def test_sensor_icon(temperature_sensor): - """Test the icon property.""" - assert temperature_sensor.icon == "mdi:thermometer" +def test_sensor_device_class(temperature_sensor): + """Test the device_class property.""" + assert temperature_sensor.device_class == DEVICE_CLASS_TEMPERATURE def test_unit_of_measure(default_sensor, battery_sensor): @@ -191,22 +191,22 @@ def test_update_captured_today(captured_sensor): assert captured_sensor.state == 5 -def _test_attributes(sensor_type): +def _test_attributes(hass, sensor_type): data = _get_named_tuple({"model_id": "TEST123"}) - sensor = _get_sensor("test", sensor_type, data) + sensor = _get_sensor(hass, "test", sensor_type, data) attrs = sensor.extra_state_attributes assert attrs.get(ATTR_ATTRIBUTION) == "Data provided by arlo.netgear.com" assert attrs.get("brand") == "Netgear Arlo" assert attrs.get("model") == "TEST123" -def test_state_attributes(): +def test_state_attributes(hass): """Test attributes for camera sensor types.""" - _test_attributes("battery_level") - _test_attributes("signal_strength") - _test_attributes("temperature") - _test_attributes("humidity") - _test_attributes("air_quality") + _test_attributes(hass, "battery_level") + _test_attributes(hass, "signal_strength") + _test_attributes(hass, "temperature") + _test_attributes(hass, "humidity") + _test_attributes(hass, "air_quality") def test_attributes_total_cameras(cameras_sensor): @@ -217,17 +217,17 @@ def test_attributes_total_cameras(cameras_sensor): assert attrs.get("model") is None -def _test_update(sensor_type, key, value): +def _test_update(hass, sensor_type, key, value): data = _get_named_tuple({key: value}) - sensor = _get_sensor("test", sensor_type, data) + sensor = _get_sensor(hass, "test", sensor_type, data) sensor.update() assert sensor.state == value -def test_update(): +def test_update(hass): """Test update method for direct transcription sensor types.""" - _test_update("battery_level", "battery_level", 100) - _test_update("signal_strength", "signal_strength", 100) - _test_update("temperature", "ambient_temperature", 21.4) - _test_update("humidity", "ambient_humidity", 45.1) - _test_update("air_quality", "ambient_air_quality", 14.2) + _test_update(hass, "battery_level", "battery_level", 100) + _test_update(hass, "signal_strength", "signal_strength", 100) + _test_update(hass, "temperature", "ambient_temperature", 21.4) + _test_update(hass, "humidity", "ambient_humidity", 45.1) + _test_update(hass, "air_quality", "ambient_air_quality", 14.2) diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index e59efcd7bcf..545feee21a5 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -2,8 +2,15 @@ from datetime import timedelta from bond_api import Action, DeviceType +import pytest from homeassistant import core +from homeassistant.components.bond.const import DOMAIN +from homeassistant.components.bond.light import ( + SERVICE_START_DECREASING_BRIGHTNESS, + SERVICE_START_INCREASING_BRIGHTNESS, + SERVICE_STOP, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -16,6 +23,7 @@ from homeassistant.const import ( 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 @@ -98,6 +106,21 @@ def fireplace_with_light(name: str): } +def light_brightness_increase_decrease_only(name: str): + """Create a light that can only increase or decrease brightness.""" + return { + "name": name, + "type": DeviceType.LIGHT, + "actions": [ + Action.TURN_LIGHT_ON, + Action.TURN_LIGHT_OFF, + Action.START_INCREASING_BRIGHTNESS, + Action.START_DECREASING_BRIGHTNESS, + Action.STOP, + ], + } + + async def test_fan_entity_registry(hass: core.HomeAssistant): """Tests that fan with light devices are registered in the entity registry.""" await setup_platform( @@ -231,6 +254,133 @@ async def test_no_trust_state(hass: core.HomeAssistant): assert device.attributes.get(ATTR_ASSUMED_STATE) is not True +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( + hass, + LIGHT_DOMAIN, + light_brightness_increase_decrease_only("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_START_INCREASING_BRIGHTNESS, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action(Action.START_INCREASING_BRIGHTNESS) + ) + + +async def test_light_start_increasing_brightness_missing_service( + hass: core.HomeAssistant, +): + """Tests a light does not have start increasing brightness throws.""" + await setup_platform( + hass, LIGHT_DOMAIN, light("name-1"), bond_device_id="test-device-id" + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_START_INCREASING_BRIGHTNESS, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_light_start_decreasing_brightness(hass: core.HomeAssistant): + """Tests a light that can only increase or decrease brightness delegates to API can start decreasing brightness.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light_brightness_increase_decrease_only("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_START_DECREASING_BRIGHTNESS, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with( + "test-device-id", Action(Action.START_DECREASING_BRIGHTNESS) + ) + + +async def test_light_start_decreasing_brightness_missing_service( + hass: core.HomeAssistant, +): + """Tests a light does not have start decreasing brightness throws.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_START_DECREASING_BRIGHTNESS, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_light_stop(hass: core.HomeAssistant): + """Tests a light that can only increase or decrease brightness delegates to API can stop.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light_brightness_increase_decrease_only("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_STOP, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_bond_action.assert_called_once_with("test-device-id", Action(Action.STOP)) + + +async def test_light_stop_missing_service( + hass: core.HomeAssistant, +): + """Tests a light does not have stop throws.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + with pytest.raises(HomeAssistantError), patch_bond_device_state(): + await hass.services.async_call( + DOMAIN, + SERVICE_STOP, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + async def test_turn_on_light(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" await setup_platform( diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 93e2596e343..756a553f3c7 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -3,8 +3,12 @@ All containing methods are legacy helpers that should not be used by new components. Instead call the service directly. """ +from unittest.mock import Mock + from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM +EMPTY_8_6_JPEG = b"empty_8_6" + def mock_camera_prefs(hass, entity_id, prefs=None): """Fixture for cloud component.""" @@ -13,3 +17,16 @@ def mock_camera_prefs(hass, entity_id, prefs=None): prefs_to_set.update(prefs) hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set return prefs_to_set + + +def mock_turbo_jpeg( + first_width=None, second_width=None, first_height=None, second_height=None +): + """Mock a TurboJPEG instance.""" + mocked_turbo_jpeg = Mock() + mocked_turbo_jpeg.decode_header.side_effect = [ + (first_width, first_height, 0, 0), + (second_width, second_height, 0, 0), + ] + mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG + return mocked_turbo_jpeg diff --git a/tests/components/camera/test_img_util.py b/tests/components/camera/test_img_util.py new file mode 100644 index 00000000000..35670b8f8d6 --- /dev/null +++ b/tests/components/camera/test_img_util.py @@ -0,0 +1,118 @@ +"""Test img_util module.""" +from unittest.mock import patch + +import pytest +from turbojpeg import TurboJPEG + +from homeassistant.components.camera import Image +from homeassistant.components.camera.img_util import ( + TurboJPEGSingleton, + find_supported_scaling_factor, + scale_jpeg_camera_image, +) + +from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg + +EMPTY_16_12_JPEG = b"empty_16_12" + + +def _clear_turbojpeg_singleton(): + TurboJPEGSingleton.__instance = None + + +def _reset_turbojpeg_singleton(): + TurboJPEGSingleton.__instance = TurboJPEG() + + +def test_turbojpeg_singleton(): + """Verify the instance always gives back the same.""" + _clear_turbojpeg_singleton() + assert TurboJPEGSingleton.instance() == TurboJPEGSingleton.instance() + + +def test_scale_jpeg_camera_image(): + """Test we can scale a jpeg image.""" + _clear_turbojpeg_singleton() + + camera_image = Image("image/jpeg", EMPTY_16_12_JPEG) + + turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) + with patch("turbojpeg.TurboJPEG", return_value=False): + TurboJPEGSingleton() + assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content + + turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) + turbo_jpeg.decode_header.side_effect = OSError + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): + TurboJPEGSingleton() + assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content + + turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): + TurboJPEGSingleton() + assert scale_jpeg_camera_image(camera_image, 16, 12) == EMPTY_16_12_JPEG + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=8, second_height=6 + ) + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): + TurboJPEGSingleton() + jpeg_bytes = scale_jpeg_camera_image(camera_image, 8, 6) + + assert jpeg_bytes == EMPTY_8_6_JPEG + + turbo_jpeg = mock_turbo_jpeg( + first_width=640, first_height=480, second_width=640, second_height=480 + ) + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): + TurboJPEGSingleton() + jpeg_bytes = scale_jpeg_camera_image(camera_image, 320, 480) + + assert jpeg_bytes == EMPTY_16_12_JPEG + + +def test_turbojpeg_load_failure(): + """Handle libjpegturbo not being installed.""" + _clear_turbojpeg_singleton() + with patch("turbojpeg.TurboJPEG", side_effect=Exception): + TurboJPEGSingleton() + assert TurboJPEGSingleton.instance() is False + + _clear_turbojpeg_singleton() + TurboJPEGSingleton() + assert TurboJPEGSingleton.instance() is not None + + +SCALE_TEST_EXPECTED = [ + (5782, 3946, 640, 480, (1, 8)), # Maximum scale + (1600, 1200, 640, 480, (1, 2)), # Equal scale for width and height + (1600, 1200, 1400, 1050, (7, 8)), # Equal scale for width and height + (1600, 1200, 1200, 900, (3, 4)), # Equal scale for width and height + (1600, 1200, 1000, 750, (5, 8)), # Equal scale for width and height + (1600, 1200, 600, 450, (3, 8)), # Equal scale for width and height + (1600, 1200, 400, 300, (1, 4)), # Equal scale for width and height + (1600, 1200, 401, 300, (3, 8)), # Width is just a little to big, next size up + (640, 480, 330, 200, (5, 8)), # Preserve width clarity + (640, 480, 300, 260, (5, 8)), # Preserve height clarity + (640, 480, 1200, 480, None), # Request larger width - no scaling + (640, 480, 640, 480, None), # Request same - no scaling + (640, 480, 640, 270, None), # Request smaller height - no scaling + (640, 480, 320, 480, None), # Request smaller width - no scaling +] + + +@pytest.mark.parametrize( + "image_width, image_height, input_width, input_height, scaling_factor", + SCALE_TEST_EXPECTED, +) +def test_find_supported_scaling_factor( + image_width, image_height, input_width, input_height, scaling_factor +): + """Test we always get an image of at least the size we ask if its big enough.""" + + assert ( + find_supported_scaling_factor( + image_width, image_height, input_width, input_height + ) + == scaling_factor + ) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 7c7890a3e5f..df4b64e4310 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -20,6 +20,8 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg + from tests.components.camera import common @@ -75,6 +77,96 @@ async def test_get_image_from_camera(hass, image_mock_url): assert image.content == b"Test" +async def test_legacy_async_get_image_signature_warns_only_once( + hass, image_mock_url, caplog +): + """Test that we only warn once when we encounter a legacy async_get_image function signature.""" + + async def _legacy_async_camera_image(self): + return b"Image" + + with patch( + "homeassistant.components.demo.camera.DemoCamera.async_camera_image", + new=_legacy_async_camera_image, + ): + image = await camera.async_get_image(hass, "camera.demo_camera") + assert image.content == b"Image" + assert "does not support requesting width and height" in caplog.text + caplog.clear() + + image = await camera.async_get_image(hass, "camera.demo_camera") + assert image.content == b"Image" + assert "does not support requesting width and height" not in caplog.text + + +async def test_get_image_from_camera_with_width_height(hass, image_mock_url): + """Grab an image from camera entity with width and height.""" + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=300, second_height=200 + ) + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton.instance", + return_value=turbo_jpeg, + ), patch( + "homeassistant.components.demo.camera.Path.read_bytes", + autospec=True, + return_value=b"Test", + ) as mock_camera: + image = await camera.async_get_image( + hass, "camera.demo_camera", width=640, height=480 + ) + + assert mock_camera.called + assert image.content == b"Test" + + +async def test_get_image_from_camera_with_width_height_scaled(hass, image_mock_url): + """Grab an image from camera entity with width and height and scale it.""" + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=300, second_height=200 + ) + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton.instance", + return_value=turbo_jpeg, + ), patch( + "homeassistant.components.demo.camera.Path.read_bytes", + autospec=True, + return_value=b"Valid jpeg", + ) as mock_camera: + image = await camera.async_get_image( + hass, "camera.demo_camera", width=4, height=3 + ) + + assert mock_camera.called + assert image.content_type == "image/jpg" + assert image.content == EMPTY_8_6_JPEG + + +async def test_get_image_from_camera_not_jpeg(hass, image_mock_url): + """Grab an image from camera entity that we cannot scale.""" + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=300, second_height=200 + ) + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton.instance", + return_value=turbo_jpeg, + ), patch( + "homeassistant.components.demo.camera.Path.read_bytes", + autospec=True, + return_value=b"png", + ) as mock_camera: + image = await camera.async_get_image( + hass, "camera.demo_camera_png", width=4, height=3 + ) + + assert mock_camera.called + assert image.content_type == "image/png" + assert image.content == b"png" + + async def test_get_stream_source_from_camera(hass, mock_camera): """Fetch stream source from camera entity.""" @@ -153,7 +245,7 @@ async def test_websocket_camera_thumbnail(hass, hass_ws_client, mock_camera): assert msg["id"] == 5 assert msg["type"] == TYPE_RESULT assert msg["success"] - assert msg["result"]["content_type"] == "image/jpeg" + assert msg["result"]["content_type"] == "image/jpg" assert msg["result"]["content"] == base64.b64encode(b"Test").decode("utf-8") diff --git a/tests/components/cloudflare/test_config_flow.py b/tests/components/cloudflare/test_config_flow.py index 230f4c3647f..16177850ad5 100644 --- a/tests/components/cloudflare/test_config_flow.py +++ b/tests/components/cloudflare/test_config_flow.py @@ -55,7 +55,7 @@ async def test_user_form(hass, cfupdate_flow): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "records" - assert result["errors"] == {} + assert result["errors"] is None with _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 5fcab6605bd..231a5128585 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -6,7 +6,7 @@ from homeassistant.components.coinbase.const import ( ) from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN -from .const import GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2, MOCK_ACCOUNTS_RESPONSE +from .const import GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2, MOCK_ACCOUNTS_RESPONSE from tests.common import MockConfigEntry @@ -60,11 +60,11 @@ def mock_get_exchange_rates(): """Return a heavily reduced mock list of exchange rates for testing.""" return { "currency": "USD", - "rates": {GOOD_EXCHNAGE_RATE_2: "0.109", GOOD_EXCHNAGE_RATE: "0.00002"}, + "rates": {GOOD_EXCHANGE_RATE_2: "0.109", GOOD_EXCHANGE_RATE: "0.00002"}, } -async def init_mock_coinbase(hass): +async def init_mock_coinbase(hass, currencies=None, rates=None): """Init Coinbase integration for testing.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -72,8 +72,8 @@ async def init_mock_coinbase(hass): title="Test User", data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, options={ - CONF_CURRENCIES: [], - CONF_EXCHANGE_RATES: [], + CONF_CURRENCIES: currencies or [], + CONF_EXCHANGE_RATES: rates or [], }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 7d36d0be9a7..082c986aa59 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -3,8 +3,8 @@ GOOD_CURRENCY = "BTC" GOOD_CURRENCY_2 = "USD" GOOD_CURRENCY_3 = "EUR" -GOOD_EXCHNAGE_RATE = "BTC" -GOOD_EXCHNAGE_RATE_2 = "ATOM" +GOOD_EXCHANGE_RATE = "BTC" +GOOD_EXCHANGE_RATE_2 = "ATOM" BAD_CURRENCY = "ETH" BAD_EXCHANGE_RATE = "ETH" @@ -15,6 +15,15 @@ MOCK_ACCOUNTS_RESPONSE = [ "id": "123456789", "name": "BTC Wallet", "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, + "type": "wallet", + }, + { + "balance": {"amount": "100.00", "currency": GOOD_CURRENCY}, + "currency": GOOD_CURRENCY, + "id": "abcdefg", + "name": "BTC Vault", + "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, + "type": "vault", }, { "balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, @@ -22,5 +31,6 @@ MOCK_ACCOUNTS_RESPONSE = [ "id": "987654321", "name": "USD Wallet", "native_balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, + "type": "fiat", }, ] diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index d153cecc249..fa13648ee71 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -19,7 +19,7 @@ from .common import ( mock_get_exchange_rates, mocked_get_accounts, ) -from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHNAGE_RATE +from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE from tests.common import MockConfigEntry @@ -160,7 +160,7 @@ async def test_option_form(hass): result["flow_id"], user_input={ CONF_CURRENCIES: [GOOD_CURRENCY], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE], }, ) assert result2["type"] == "create_entry" diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index 36f0ff95472..efb5ba85f73 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components.coinbase.const import ( + API_TYPE_VAULT, CONF_CURRENCIES, CONF_EXCHANGE_RATES, CONF_YAML_API_TOKEN, @@ -22,8 +23,8 @@ from .common import ( from .const import ( GOOD_CURRENCY, GOOD_CURRENCY_2, - GOOD_EXCHNAGE_RATE, - GOOD_EXCHNAGE_RATE_2, + GOOD_EXCHANGE_RATE, + GOOD_EXCHANGE_RATE_2, ) @@ -34,7 +35,7 @@ async def test_setup(hass): CONF_API_KEY: "123456", CONF_YAML_API_TOKEN: "AbCDeF", CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], } } with patch( @@ -54,7 +55,7 @@ async def test_setup(hass): assert entries[0].source == config_entries.SOURCE_IMPORT assert entries[0].options == { CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], } @@ -103,7 +104,7 @@ async def test_option_updates(hass: HomeAssistant): result["flow_id"], user_input={ CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], }, ) await hass.async_block_till_done() @@ -126,7 +127,7 @@ async def test_option_updates(hass: HomeAssistant): ] assert currencies == [GOOD_CURRENCY, GOOD_CURRENCY_2] - assert rates == [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2] + assert rates == [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2] result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() @@ -134,7 +135,7 @@ async def test_option_updates(hass: HomeAssistant): result["flow_id"], user_input={ CONF_CURRENCIES: [GOOD_CURRENCY], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE], + CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE], }, ) await hass.async_block_till_done() @@ -157,4 +158,28 @@ async def test_option_updates(hass: HomeAssistant): ] assert currencies == [GOOD_CURRENCY] - assert rates == [GOOD_EXCHNAGE_RATE] + assert rates == [GOOD_EXCHANGE_RATE] + + +async def test_ignore_vaults_wallets(hass: HomeAssistant): + """Test vaults are ignored in wallet sensors.""" + + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY]) + await hass.async_block_till_done() + + registry = entity_registry.async_get(hass) + entities = entity_registry.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + assert len(entities) == 1 + entity = entities[0] + assert API_TYPE_VAULT not in entity.original_name.lower() diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index b8de1f10d85..5bafdc2fbb6 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -6,24 +6,21 @@ from pydeconz.sensor import ( ANCILLARY_CONTROL_ARMED_AWAY, ANCILLARY_CONTROL_ARMED_NIGHT, ANCILLARY_CONTROL_ARMED_STAY, + ANCILLARY_CONTROL_ARMING_AWAY, + ANCILLARY_CONTROL_ARMING_NIGHT, + ANCILLARY_CONTROL_ARMING_STAY, ANCILLARY_CONTROL_DISARMED, ANCILLARY_CONTROL_ENTRY_DELAY, ANCILLARY_CONTROL_EXIT_DELAY, - ANCILLARY_CONTROL_NOT_READY_TO_ARM, + ANCILLARY_CONTROL_IN_ALARM, + ANCILLARY_CONTROL_NOT_READY, ) from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) -from homeassistant.components.deconz.alarm_control_panel import ( - CONF_ALARM_PANEL_STATE, - PANEL_ENTRY_DELAY, - PANEL_EXIT_DELAY, - PANEL_NOT_READY_TO_ARM, - SERVICE_ALARM_PANEL_STATE, -) -from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, @@ -32,7 +29,10 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, STATE_UNAVAILABLE, ) @@ -52,38 +52,68 @@ async def test_no_sensors(hass, aioclient_mock): async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): """Test successful creation of alarm control panel entities.""" data = { + "alarmsystems": { + "0": { + "name": "default", + "config": { + "armmode": "armed_away", + "configured": True, + "disarmed_entry_delay": 0, + "disarmed_exit_delay": 0, + "armed_away_entry_delay": 120, + "armed_away_exit_delay": 120, + "armed_away_trigger_duration": 120, + "armed_stay_entry_delay": 120, + "armed_stay_exit_delay": 120, + "armed_stay_trigger_duration": 120, + "armed_night_entry_delay": 120, + "armed_night_exit_delay": 120, + "armed_night_trigger_duration": 120, + }, + "state": {"armstate": "armed_away", "seconds_remaining": 0}, + "devices": { + "00:00:00:00:00:00:00:00-00": {}, + "00:15:8d:00:02:af:95:f9-01-0101": { + "armmask": "AN", + "trigger": "state/vibration", + }, + }, + } + }, "sensors": { "0": { "config": { - "armed": "disarmed", - "enrolled": 0, + "battery": 95, + "enrolled": 1, "on": True, - "panel": "disarmed", "pending": [], "reachable": True, }, "ep": 1, - "etag": "3c4008d74035dfaa1f0bb30d24468b12", - "lastseen": "2021-04-02T13:07Z", - "manufacturername": "Universal Electronics Inc", - "modelid": "URC4450BC0-X-R", + "etag": "5aaa1c6bae8501f59929539c6e8f44d6", + "lastseen": "2021-07-25T18:07Z", + "manufacturername": "lk", + "modelid": "ZB-KeypadGeneric-D0002", "name": "Keypad", "state": { - "action": "armed_away,1111,55", - "lastupdated": "2021-04-02T13:08:18.937", + "action": "armed_stay", + "lastupdated": "2021-07-25T18:02:51.172", "lowbattery": False, - "tampered": True, + "panel": "exit_delay", + "seconds_remaining": 55, + "tampered": False, }, + "swversion": "3.13", "type": "ZHAAncillaryControl", - "uniqueid": "00:0d:6f:00:13:4f:61:39-01-0501", + "uniqueid": "00:00:00:00:00:00:00:00-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("alarm_control_panel.keypad").state == STATE_ALARM_DISARMED + assert len(hass.states.async_all()) == 3 + assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_PENDING # Event signals alarm control panel armed away @@ -92,7 +122,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "config": {"armed": ANCILLARY_CONTROL_ARMED_AWAY}, + "state": {"panel": ANCILLARY_CONTROL_ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -106,7 +136,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "config": {"armed": ANCILLARY_CONTROL_ARMED_NIGHT}, + "state": {"panel": ANCILLARY_CONTROL_ARMED_NIGHT}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -122,29 +152,13 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "config": {"armed": ANCILLARY_CONTROL_ARMED_STAY}, + "state": {"panel": ANCILLARY_CONTROL_ARMED_STAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_ARMED_HOME - # Event signals alarm control panel armed night - - event_changed_sensor = { - "t": "event", - "e": "changed", - "r": "sensors", - "id": "0", - "config": {"armed": ANCILLARY_CONTROL_ARMED_NIGHT}, - } - await mock_deconz_websocket(data=event_changed_sensor) - await hass.async_block_till_done() - - assert ( - hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_ARMED_NIGHT - ) - # Event signals alarm control panel disarmed event_changed_sensor = { @@ -152,116 +166,139 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "config": {"armed": ANCILLARY_CONTROL_DISARMED}, + "state": {"panel": ANCILLARY_CONTROL_DISARMED}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_DISARMED + # Event signals alarm control panel arming + + for arming_event in { + ANCILLARY_CONTROL_ARMING_AWAY, + ANCILLARY_CONTROL_ARMING_NIGHT, + ANCILLARY_CONTROL_ARMING_STAY, + }: + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "0", + "state": {"panel": arming_event}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_ARMING + + # Event signals alarm control panel pending + + for pending_event in {ANCILLARY_CONTROL_ENTRY_DELAY, ANCILLARY_CONTROL_EXIT_DELAY}: + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "0", + "state": {"panel": pending_event}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert ( + hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_PENDING + ) + + # Event signals alarm control panel triggered + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "0", + "state": {"panel": ANCILLARY_CONTROL_IN_ALARM}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_TRIGGERED + + # Event signals alarm control panel unknown state keeps previous state + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "0", + "state": {"panel": ANCILLARY_CONTROL_NOT_READY}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_TRIGGERED + # Verify service calls - mock_deconz_put_request(aioclient_mock, config_entry.data, "/sensors/0/config") - # Service set alarm to away mode + mock_deconz_put_request( + aioclient_mock, config_entry.data, "/alarmsystems/0/arm_away" + ) + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_AWAY, - {ATTR_ENTITY_ID: "alarm_control_panel.keypad"}, + {ATTR_ENTITY_ID: "alarm_control_panel.keypad", ATTR_CODE: "1234"}, blocking=True, ) - assert aioclient_mock.mock_calls[1][2] == { - "armed": ANCILLARY_CONTROL_ARMED_AWAY, - "panel": ANCILLARY_CONTROL_ARMED_AWAY, - } + assert aioclient_mock.mock_calls[1][2] == {"code0": "1234"} # Service set alarm to home mode + mock_deconz_put_request( + aioclient_mock, config_entry.data, "/alarmsystems/0/arm_stay" + ) + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_HOME, - {ATTR_ENTITY_ID: "alarm_control_panel.keypad"}, + {ATTR_ENTITY_ID: "alarm_control_panel.keypad", ATTR_CODE: "2345"}, blocking=True, ) - assert aioclient_mock.mock_calls[2][2] == { - "armed": ANCILLARY_CONTROL_ARMED_STAY, - "panel": ANCILLARY_CONTROL_ARMED_STAY, - } + assert aioclient_mock.mock_calls[2][2] == {"code0": "2345"} # Service set alarm to night mode + mock_deconz_put_request( + aioclient_mock, config_entry.data, "/alarmsystems/0/arm_night" + ) + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_NIGHT, - {ATTR_ENTITY_ID: "alarm_control_panel.keypad"}, + {ATTR_ENTITY_ID: "alarm_control_panel.keypad", ATTR_CODE: "3456"}, blocking=True, ) - assert aioclient_mock.mock_calls[3][2] == { - "armed": ANCILLARY_CONTROL_ARMED_NIGHT, - "panel": ANCILLARY_CONTROL_ARMED_NIGHT, - } + assert aioclient_mock.mock_calls[3][2] == {"code0": "3456"} # Service set alarm to disarmed + mock_deconz_put_request(aioclient_mock, config_entry.data, "/alarmsystems/0/disarm") + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, - {ATTR_ENTITY_ID: "alarm_control_panel.keypad"}, + {ATTR_ENTITY_ID: "alarm_control_panel.keypad", ATTR_CODE: "4567"}, blocking=True, ) - assert aioclient_mock.mock_calls[4][2] == { - "armed": ANCILLARY_CONTROL_DISARMED, - "panel": ANCILLARY_CONTROL_DISARMED, - } - - # Verify entity service calls - - # Service set panel to entry delay - - await hass.services.async_call( - DECONZ_DOMAIN, - SERVICE_ALARM_PANEL_STATE, - { - ATTR_ENTITY_ID: "alarm_control_panel.keypad", - CONF_ALARM_PANEL_STATE: PANEL_ENTRY_DELAY, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[5][2] == {"panel": ANCILLARY_CONTROL_ENTRY_DELAY} - - # Service set panel to exit delay - - await hass.services.async_call( - DECONZ_DOMAIN, - SERVICE_ALARM_PANEL_STATE, - { - ATTR_ENTITY_ID: "alarm_control_panel.keypad", - CONF_ALARM_PANEL_STATE: PANEL_EXIT_DELAY, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[6][2] == {"panel": ANCILLARY_CONTROL_EXIT_DELAY} - - # Service set panel to not ready to arm - - await hass.services.async_call( - DECONZ_DOMAIN, - SERVICE_ALARM_PANEL_STATE, - { - ATTR_ENTITY_ID: "alarm_control_panel.keypad", - CONF_ALARM_PANEL_STATE: PANEL_NOT_READY_TO_ARM, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[7][2] == { - "panel": ANCILLARY_CONTROL_NOT_READY_TO_ARM - } + assert aioclient_mock.mock_calls[4][2] == {"code0": "4567"} await hass.config_entries.async_unload(config_entry.entry_id) states = hass.states.async_all() - assert len(states) == 2 + assert len(states) == 3 for state in states: assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index 418545f11cf..ca76631728e 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -2,13 +2,20 @@ from unittest.mock import patch +from pydeconz.sensor import ( + ANCILLARY_CONTROL_ARMED_AWAY, + ANCILLARY_CONTROL_EMERGENCY, + ANCILLARY_CONTROL_FIRE, + ANCILLARY_CONTROL_INVALID_CODE, + ANCILLARY_CONTROL_PANIC, +) + from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.deconz_event import ( CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT, ) from homeassistant.const import ( - CONF_CODE, CONF_DEVICE_ID, CONF_EVENT, CONF_ID, @@ -200,39 +207,69 @@ async def test_deconz_events(hass, aioclient_mock, mock_deconz_websocket): async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): """Test successful creation of deconz alarm events.""" data = { + "alarmsystems": { + "0": { + "name": "default", + "config": { + "armmode": "armed_away", + "configured": True, + "disarmed_entry_delay": 0, + "disarmed_exit_delay": 0, + "armed_away_entry_delay": 120, + "armed_away_exit_delay": 120, + "armed_away_trigger_duration": 120, + "armed_stay_entry_delay": 120, + "armed_stay_exit_delay": 120, + "armed_stay_trigger_duration": 120, + "armed_night_entry_delay": 120, + "armed_night_exit_delay": 120, + "armed_night_trigger_duration": 120, + }, + "state": {"armstate": "armed_away", "seconds_remaining": 0}, + "devices": { + "00:00:00:00:00:00:00:01-00": {}, + "00:15:8d:00:02:af:95:f9-01-0101": { + "armmask": "AN", + "trigger": "state/vibration", + }, + }, + } + }, "sensors": { "1": { "config": { - "armed": "disarmed", - "enrolled": 0, + "battery": 95, + "enrolled": 1, "on": True, - "panel": "disarmed", "pending": [], "reachable": True, }, "ep": 1, - "etag": "3c4008d74035dfaa1f0bb30d24468b12", - "lastseen": "2021-04-02T13:07Z", - "manufacturername": "Universal Electronics Inc", - "modelid": "URC4450BC0-X-R", + "etag": "5aaa1c6bae8501f59929539c6e8f44d6", + "lastseen": "2021-07-25T18:07Z", + "manufacturername": "lk", + "modelid": "ZB-KeypadGeneric-D0002", "name": "Keypad", "state": { - "action": "armed_away,1111,55", - "lastupdated": "2021-04-02T13:08:18.937", + "action": "invalid_code", + "lastupdated": "2021-07-25T18:02:51.172", "lowbattery": False, - "tampered": True, + "panel": "exit_delay", + "seconds_remaining": 55, + "tampered": False, }, + "swversion": "3.13", "type": "ZHAAncillaryControl", - "uniqueid": "00:00:00:00:00:00:00:01-01-0501", + "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) device_registry = await hass.helpers.device_registry.async_get_registry() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 # 1 alarm control device + 2 additional devices for deconz service and host assert ( len(async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 3 @@ -240,14 +277,14 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): captured_events = async_capture_events(hass, CONF_DECONZ_ALARM_EVENT) - # Armed away event + # Emergency event event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": "armed_away,1234,1"}, + "state": {"action": ANCILLARY_CONTROL_EMERGENCY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -261,86 +298,113 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: "armed_away", - CONF_CODE: "1234", + CONF_EVENT: ANCILLARY_CONTROL_EMERGENCY, } - # Unsupported events - - # Bad action string; string is None + # Fire event event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": None}, + "state": {"action": ANCILLARY_CONTROL_FIRE}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + device = device_registry.async_get_device( + identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:01")} + ) - # Bad action string; empty string + assert len(captured_events) == 2 + assert captured_events[1].data == { + CONF_ID: "keypad", + CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", + CONF_DEVICE_ID: device.id, + CONF_EVENT: ANCILLARY_CONTROL_FIRE, + } + + # Invalid code event event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ""}, + "state": {"action": ANCILLARY_CONTROL_INVALID_CODE}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + device = device_registry.async_get_device( + identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:01")} + ) - # Bad action string; too few "," + assert len(captured_events) == 3 + assert captured_events[2].data == { + CONF_ID: "keypad", + CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", + CONF_DEVICE_ID: device.id, + CONF_EVENT: ANCILLARY_CONTROL_INVALID_CODE, + } + + # Panic event event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": "armed_away,1234"}, + "state": {"action": ANCILLARY_CONTROL_PANIC}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + device = device_registry.async_get_device( + identifiers={(DECONZ_DOMAIN, "00:00:00:00:00:00:00:01")} + ) - # Bad action string; unsupported command + assert len(captured_events) == 4 + assert captured_events[3].data == { + CONF_ID: "keypad", + CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", + CONF_DEVICE_ID: device.id, + CONF_EVENT: ANCILLARY_CONTROL_PANIC, + } + + # Only care for changes to specific action events event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "state": {"action": "unsupported,1234,1"}, + "state": {"action": ANCILLARY_CONTROL_ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + assert len(captured_events) == 4 - # Only care for changes to action + # Only care for action events event_changed_sensor = { "t": "event", "e": "changed", "r": "sensors", "id": "1", - "config": {"panel": "armed_away"}, + "state": {"panel": ANCILLARY_CONTROL_ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() - assert len(captured_events) == 1 + assert len(captured_events) == 4 await hass.config_entries.async_unload(config_entry.entry_id) states = hass.states.async_all() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 for state in states: assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/demo/test_number.py b/tests/components/demo/test_number.py index 711332b7817..82536b0d2f8 100644 --- a/tests/components/demo/test_number.py +++ b/tests/components/demo/test_number.py @@ -67,7 +67,7 @@ async def test_set_value_bad_range(hass): state = hass.states.get(ENTITY_VOLUME) assert state.state == "42.0" - with pytest.raises(vol.Invalid): + with pytest.raises(ValueError): await hass.services.async_call( DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 160e6354b8b..13190ed4b32 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,6 +1,7 @@ """The test for light device automation.""" import pytest +from homeassistant.components import device_automation import homeassistant.components.automation as automation from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON @@ -372,6 +373,76 @@ async def test_websocket_get_no_condition_capabilities( assert capabilities == expected_capabilities +async def test_async_get_device_automations_single_device_trigger( + hass, device_reg, entity_reg +): + """Test we get can fetch the triggers for a device id.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations( + hass, "trigger", [device_entry.id] + ) + assert device_entry.id in result + assert len(result[device_entry.id]) == 2 + + +async def test_async_get_device_automations_all_devices_trigger( + hass, device_reg, entity_reg +): + """Test we get can fetch all the triggers when no device id is passed.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations(hass, "trigger") + assert device_entry.id in result + assert len(result[device_entry.id]) == 2 + + +async def test_async_get_device_automations_all_devices_condition( + hass, device_reg, entity_reg +): + """Test we get can fetch all the conditions when no device id is passed.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations(hass, "condition") + assert device_entry.id in result + assert len(result[device_entry.id]) == 2 + + +async def test_async_get_device_automations_all_devices_action( + hass, device_reg, entity_reg +): + """Test we get can fetch all the actions when no device id is passed.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations(hass, "action") + assert device_entry.id in result + assert len(result[device_entry.id]) == 3 + + async def test_websocket_get_trigger_capabilities( hass, hass_ws_client, device_reg, entity_reg ): diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index ab7b3a4d479..9ef6bccfab5 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -7,6 +7,7 @@ from dsmr_parser.obis_references import ( EQUIPMENT_IDENTIFIER, EQUIPMENT_IDENTIFIER_GAS, LUXEMBOURG_EQUIPMENT_IDENTIFIER, + P1_MESSAGE_TIMESTAMP, ) from dsmr_parser.objects import CosemObject import pytest @@ -44,6 +45,7 @@ async def dsmr_connection_send_validate_fixture(hass): protocol.telegram = { EQUIPMENT_IDENTIFIER: CosemObject([{"value": "12345678", "unit": ""}]), EQUIPMENT_IDENTIFIER_GAS: CosemObject([{"value": "123456789", "unit": ""}]), + P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), } async def connection_factory(*args, **kwargs): @@ -57,6 +59,10 @@ async def dsmr_connection_send_validate_fixture(hass): [{"value": "123456789", "unit": ""}] ), } + if args[1] == "5S": + protocol.telegram = { + P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + } return (transport, protocol) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 006893a81e8..d56cd3f2eb8 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.dsmr import DOMAIN, config_flow from tests.common import MockConfigEntry SERIAL_DATA = {"serial_id": "12345678", "serial_id_gas": "123456789"} +SERIAL_DATA_SWEDEN = {"serial_id": None, "serial_id_gas": None} def com_port(): @@ -482,6 +483,29 @@ async def test_import_luxembourg(hass, dsmr_connection_send_validate_fixture): assert result["data"] == {**entry_data, **SERIAL_DATA} +async def test_import_sweden(hass, dsmr_connection_send_validate_fixture): + """Test we can import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5S", + "precision": 4, + "reconnect_interval": 30, + } + + with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=entry_data, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "/dev/ttyUSB0" + assert result["data"] == {**entry_data, **SERIAL_DATA_SWEDEN} + + def test_get_serial_by_id_no_dir(): """Test serial by id conversion if there's no /dev/serial/by-id.""" p1 = patch("os.path.isdir", MagicMock(return_value=False)) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 90194eaeb6b..6accf7c40da 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -14,16 +14,17 @@ from unittest.mock import DEFAULT, MagicMock from homeassistant import config_entries from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import ( - ATTR_LAST_RESET, 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, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, STATE_UNKNOWN, @@ -104,7 +105,7 @@ async def test_default_setup(hass, dsmr_connection_fixture): GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), } @@ -135,7 +136,6 @@ async def test_default_setup(hass, dsmr_connection_fixture): assert power_consumption.state == STATE_UNKNOWN assert power_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER assert power_consumption.attributes.get(ATTR_ICON) is None - assert power_consumption.attributes.get(ATTR_LAST_RESET) is None assert power_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None @@ -157,17 +157,16 @@ async def test_default_setup(hass, dsmr_connection_fixture): assert power_tariff.state == "low" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -228,7 +227,7 @@ async def test_v4_meter(hass, dsmr_connection_fixture): HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), @@ -256,18 +255,17 @@ async def test_v4_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "low" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -299,7 +297,7 @@ async def test_v5_meter(hass, dsmr_connection_fixture): HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), @@ -327,17 +325,16 @@ async def test_v5_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "low" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -370,7 +367,7 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL: CosemObject( @@ -402,8 +399,7 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "123.456" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY assert power_tariff.attributes.get(ATTR_ICON) is None - assert power_tariff.attributes.get(ATTR_LAST_RESET) is not None - assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert ( power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR ) @@ -415,10 +411,10 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -450,7 +446,7 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): BELGIUM_HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + {"value": Decimal(745.695), "unit": "m3"}, ] ), ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), @@ -478,17 +474,16 @@ async def test_belgian_meter(hass, dsmr_connection_fixture): assert power_tariff.state == "normal" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is None - assert gas_consumption.attributes.get(ATTR_ICON) == "mdi:fire" - assert gas_consumption.attributes.get(ATTR_LAST_RESET) is not None - assert gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) is DEVICE_CLASS_GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + ) assert ( gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS ) @@ -537,11 +532,75 @@ async def test_belgian_meter_low(hass, dsmr_connection_fixture): assert power_tariff.state == "low" assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) is None assert power_tariff.attributes.get(ATTR_ICON) == "mdi:flash" - assert power_tariff.attributes.get(ATTR_LAST_RESET) is None assert power_tariff.attributes.get(ATTR_STATE_CLASS) is None assert power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" +async def test_swedish_meter(hass, dsmr_connection_fixture): + """Test if v5 meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, + SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, + ) + from dsmr_parser.objects import CosemObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5S", + "precision": 4, + "reconnect_interval": 30, + "serial_id": None, + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL: CosemObject( + [{"value": Decimal(123.456), "unit": ENERGY_KILO_WATT_HOUR}] + ), + SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL: CosemObject( + [{"value": Decimal(654.321), "unit": ENERGY_KILO_WATT_HOUR}] + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to update + await asyncio.sleep(0) + + power_tariff = hass.states.get("sensor.energy_consumption_total") + assert power_tariff.state == "123.456" + assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert power_tariff.attributes.get(ATTR_ICON) is None + assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + ) + + power_tariff = hass.states.get("sensor.energy_production_total") + assert power_tariff.state == "654.321" + assert power_tariff.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert ( + power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + ) + + async def test_tcp(hass, dsmr_connection_fixture): """If proper config provided TCP connection should be made.""" (connection_factory, transport, protocol) = dsmr_connection_fixture diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index fbc926d318f..c85c71aa723 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -45,7 +45,7 @@ async def test_light_state_temperature( assert state assert state.attributes.get(ATTR_BRIGHTNESS) == 54 assert state.attributes.get(ATTR_COLOR_TEMP) == 297 - assert state.attributes.get(ATTR_HS_COLOR) is None + assert state.attributes.get(ATTR_HS_COLOR) == (27.316, 47.743) assert state.attributes.get(ATTR_COLOR_MODE) == COLOR_MODE_COLOR_TEMP assert state.attributes.get(ATTR_MIN_MIREDS) == 143 assert state.attributes.get(ATTR_MAX_MIREDS) == 344 diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 978b21e1919..542ea3296ce 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -7,9 +7,8 @@ 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_INCREASING, ) from homeassistant.components.sensor.recorder import compile_statistics from homeassistant.const import ( @@ -18,6 +17,8 @@ from homeassistant.const import ( DEVICE_CLASS_MONETARY, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, + STATE_UNKNOWN, + VOLUME_CUBIC_METERS, ) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -93,6 +94,11 @@ async def test_cost_sensor_price_entity( 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: STATE_CLASS_TOTAL_INCREASING, + } + await async_init_recorder_component(hass) energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -130,14 +136,13 @@ async def test_cost_sensor_price_entity( } now = dt_util.utcnow() - last_reset = dt_util.utc_from_timestamp(0).isoformat() # Optionally initialize dependent entities if initial_energy is not None: hass.states.async_set( usage_sensor_entity_id, initial_energy, - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) hass.states.async_set("sensor.energy_price", "1") @@ -147,9 +152,7 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - if initial_cost != "unknown": - assert state.attributes[ATTR_LAST_RESET] == now.isoformat() - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -158,18 +161,14 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "0", - { - "last_reset": last_reset, - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - }, + 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] == now.isoformat() - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -181,7 +180,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "10", - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) @@ -205,7 +204,7 @@ async def test_cost_sensor_price_entity( hass.states.async_set( usage_sensor_entity_id, "14.5", - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) @@ -217,36 +216,49 @@ async def test_cost_sensor_price_entity( assert cost_sensor_entity_id in statistics assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 - # Energy sensor is reset, with start point at 4kWh - last_reset = (now + timedelta(seconds=1)).isoformat() + # Energy sensor has a small dip, no reset should be detected hass.states.async_set( usage_sensor_entity_id, - "4", - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + "14", + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) - assert state.state == "0.0" # 0 EUR + (4-4) kWh * 2 EUR/kWh = 0 EUR + assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + + # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point + hass.states.async_set( + usage_sensor_entity_id, + "4", + energy_attributes, + ) + 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 # Energy use bumped to 10 kWh hass.states.async_set( usage_sensor_entity_id, "10", - {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) - assert state.state == "12.0" # 0 EUR + (10-4) kWh * 2 EUR/kWh = 12 EUR + assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR # Check generated statistics await async_wait_recording_done_without_instance(hass) statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) assert cost_sensor_entity_id in statistics - assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 31.0 + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: """Test energy cost price from sensor entity.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + } energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( { @@ -271,12 +283,11 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: } now = dt_util.utcnow() - last_reset = dt_util.utc_from_timestamp(0).isoformat() hass.states.async_set( "sensor.energy_consumption", 10000, - {"last_reset": last_reset, "unit_of_measurement": ENERGY_WATT_HOUR}, + energy_attributes, ) with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -289,9 +300,120 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: hass.states.async_set( "sensor.energy_consumption", 20000, - {"last_reset": last_reset, "unit_of_measurement": ENERGY_WATT_HOUR}, + energy_attributes, ) await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "5.0" + + +async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: + """Test gas cost price from sensor entity.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "gas", + "stat_energy_from": "sensor.gas_consumption", + "entity_energy_from": "sensor.gas_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.gas_consumption", + 100, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.gas_consumption_cost") + assert state.state == "0.0" + + # gas use bumped to 10 kWh + hass.states.async_set( + "sensor.gas_consumption", + 200, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gas_consumption_cost") + assert state.state == "50.0" + + +@pytest.mark.parametrize("state_class", [None]) +async def test_cost_sensor_wrong_state_class( + hass, hass_storage, caplog, state_class +) -> None: + """Test energy sensor rejects wrong state_class.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: state_class, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.energy_consumption", + 10000, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN + assert ( + f"Found unexpected state_class {state_class} for sensor.energy_consumption" + in caplog.text + ) + + # Energy use bumped to 10 kWh + hass.states.async_set( + "sensor.energy_consumption", + 20000, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py new file mode 100644 index 00000000000..9a0b2105007 --- /dev/null +++ b/tests/components/energy/test_validate.py @@ -0,0 +1,443 @@ +"""Test that validation works.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.energy import async_get_manager, validate +from homeassistant.setup import async_setup_component + +from tests.common import async_init_recorder_component + + +@pytest.fixture +def mock_is_entity_recorded(): + """Mock recorder.is_entity_recorded.""" + mocks = {} + + with patch( + "homeassistant.components.recorder.is_entity_recorded", + side_effect=lambda hass, entity_id: mocks.get(entity_id, True), + ): + yield mocks + + +@pytest.fixture(autouse=True) +async def mock_energy_manager(hass): + """Set up energy.""" + await async_init_recorder_component(hass) + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + return manager + + +async def test_validation_empty_config(hass): + """Test validating an empty config.""" + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [], + } + + +async def test_validation(hass, mock_energy_manager): + """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"}, + ) + + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_import", + "stat_energy_to": "sensor.battery_export", + }, + {"type": "solar", "stat_energy_from": "sensor.solar_production"}, + ], + "device_consumption": [{"stat_consumption": "sensor.device_cons"}], + } + ) + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[], []], + "device_consumption": [[]], + } + + +async def test_validation_device_consumption_entity_missing(hass, mock_energy_manager): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.not_exist"}]} + ) + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_not_defined", + "identifier": "sensor.not_exist", + "value": None, + } + ] + ], + } + + +async def test_validation_device_consumption_entity_unavailable( + hass, mock_energy_manager +): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.unavailable"}]} + ) + hass.states.async_set("sensor.unavailable", "unavailable", {}) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_unavailable", + "identifier": "sensor.unavailable", + "value": "unavailable", + } + ] + ], + } + + +async def test_validation_device_consumption_entity_non_numeric( + hass, mock_energy_manager +): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.non_numeric"}]} + ) + hass.states.async_set("sensor.non_numeric", "123,123.10") + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_state_non_numeric", + "identifier": "sensor.non_numeric", + "value": "123,123.10", + }, + ] + ], + } + + +async def test_validation_device_consumption_entity_unexpected_unit( + hass, mock_energy_manager +): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.unexpected_unit"}]} + ) + hass.states.async_set( + "sensor.unexpected_unit", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.unexpected_unit", + "value": "beers", + } + ] + ], + } + + +async def test_validation_device_consumption_recorder_not_tracked( + hass, mock_energy_manager, mock_is_entity_recorded +): + """Test validating device based on untracked entity.""" + mock_is_entity_recorded["sensor.not_recorded"] = False + await mock_energy_manager.async_update( + {"device_consumption": [{"stat_consumption": "sensor.not_recorded"}]} + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [], + "device_consumption": [ + [ + { + "type": "recorder_untracked", + "identifier": "sensor.not_recorded", + "value": None, + } + ] + ], + } + + +async def test_validation_solar(hass, mock_energy_manager): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + {"type": "solar", "stat_energy_from": "sensor.solar_production"} + ] + } + ) + hass.states.async_set( + "sensor.solar_production", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.solar_production", + "value": "beers", + } + ] + ], + "device_consumption": [], + } + + +async def test_validation_battery(hass, mock_energy_manager): + """Test validating missing stat for device.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_import", + "stat_energy_to": "sensor.battery_export", + } + ] + } + ) + hass.states.async_set( + "sensor.battery_import", + "10.10", + {"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"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.battery_import", + "value": "beers", + }, + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.battery_export", + "value": "beers", + }, + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid(hass, mock_energy_manager, mock_is_entity_recorded): + """Test validating grid with sensors for energy and cost/compensation.""" + mock_is_entity_recorded["sensor.grid_cost_1"] = False + mock_is_entity_recorded["sensor.grid_compensation_1"] = False + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_consumption_1", + "stat_cost": "sensor.grid_cost_1", + } + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_production_1", + "stat_compensation": "sensor.grid_compensation_1", + } + ], + } + ] + } + ) + hass.states.async_set( + "sensor.grid_consumption_1", + "10.10", + {"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"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.grid_consumption_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.grid_cost_1", + "value": None, + }, + { + "type": "entity_unexpected_unit_energy", + "identifier": "sensor.grid_production_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.grid_compensation_1", + "value": None, + }, + ] + ], + "device_consumption": [], + } + + +async def test_validation_grid_price_not_exist(hass, mock_energy_manager): + """Test validating grid with price entity that does not exist.""" + hass.states.async_set( + "sensor.grid_consumption_1", + "10.10", + {"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"}, + ) + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_consumption_1", + "entity_energy_from": "sensor.grid_consumption_1", + "entity_energy_price": "sensor.grid_price_1", + } + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_production_1", + "entity_energy_to": "sensor.grid_production_1", + "number_energy_price": 0.10, + } + ], + } + ] + } + ) + await hass.async_block_till_done() + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_not_defined", + "identifier": "sensor.grid_price_1", + "value": None, + } + ] + ], + "device_consumption": [], + } + + +@pytest.mark.parametrize( + "state, unit, expected", + ( + ( + "123,123.12", + "$/kWh", + { + "type": "entity_state_non_numeric", + "identifier": "sensor.grid_price_1", + "value": "123,123.12", + }, + ), + ( + "-100", + "$/kWh", + { + "type": "entity_negative_state", + "identifier": "sensor.grid_price_1", + "value": -100.0, + }, + ), + ( + "123", + "$/Ws", + { + "type": "entity_unexpected_unit_price", + "identifier": "sensor.grid_price_1", + "value": "$/Ws", + }, + ), + ), +) +async def test_validation_grid_price_errors( + hass, mock_energy_manager, state, unit, expected +): + """Test validating grid with price data that gives errors.""" + hass.states.async_set( + "sensor.grid_consumption_1", + "10.10", + {"unit_of_measurement": "kWh", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.grid_price_1", + state, + {"unit_of_measurement": unit, "state_class": "total_increasing"}, + ) + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_consumption_1", + "entity_energy_from": "sensor.grid_consumption_1", + "entity_energy_price": "sensor.grid_price_1", + } + ], + "flow_to": [], + } + ] + } + ) + await hass.async_block_till_done() + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [expected], + ], + "device_consumption": [], + } diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index a14a8d0986e..09a3b7aed94 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -1,10 +1,12 @@ """Test the Energy websocket API.""" +from unittest.mock import AsyncMock, Mock + import pytest from homeassistant.components.energy import data, is_configured from homeassistant.setup import async_setup_component -from tests.common import flush_store +from tests.common import MockConfigEntry, flush_store, mock_platform @pytest.fixture(autouse=True) @@ -15,6 +17,26 @@ async def setup_integration(hass): ) +@pytest.fixture +def mock_energy_platform(hass): + """Mock an energy platform.""" + hass.config.components.add("some_domain") + mock_platform( + hass, + "some_domain.energy", + Mock( + async_get_solar_forecast=AsyncMock( + return_value={ + "wh_hours": { + "2021-06-27T13:00:00+00:00": 12, + "2021-06-27T14:00:00+00:00": 8, + } + } + ) + ), + ) + + async def test_get_preferences_no_data(hass, hass_ws_client) -> None: """Test we get error if no preferences set.""" client = await hass_ws_client(hass) @@ -46,7 +68,9 @@ async def test_get_preferences_default(hass, hass_ws_client, hass_storage) -> No assert msg["result"] == data.EnergyManager.default_preferences() -async def test_save_preferences(hass, hass_ws_client, hass_storage) -> None: +async def test_save_preferences( + hass, hass_ws_client, hass_storage, mock_energy_platform +) -> None: """Test we can save preferences.""" client = await hass_ws_client(hass) @@ -104,6 +128,11 @@ async def test_save_preferences(hass, hass_ws_client, hass_storage) -> None: "stat_energy_from": "my_solar_production", "config_entry_solar_forecast": ["predicted_config_entry"], }, + { + "type": "battery", + "stat_energy_from": "my_battery_draining", + "stat_energy_to": "my_battery_charging", + }, ], "device_consumption": [{"stat_consumption": "some_device_usage"}], } @@ -135,7 +164,8 @@ async def test_save_preferences(hass, hass_ws_client, hass_storage) -> None: "cost_sensors": { "sensor.heat_pump_meter_2": "sensor.heat_pump_meter_2_cost", "sensor.return_to_grid_offpeak": "sensor.return_to_grid_offpeak_compensation", - } + }, + "solar_forecast_domains": ["some_domain"], } # Prefs with limited options @@ -211,3 +241,51 @@ async def test_handle_duplicate_from_stat(hass, hass_ws_client) -> None: assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "invalid_format" + + +async def test_validate(hass, hass_ws_client) -> None: + """Test we can validate the preferences.""" + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "energy/validate"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == { + "energy_sources": [], + "device_consumption": [], + } + + +async def test_get_solar_forecast(hass, hass_ws_client, mock_energy_platform) -> None: + """Test we get preferences.""" + entry = MockConfigEntry(domain="some_domain") + entry.add_to_hass(hass) + + manager = await data.async_get_manager(hass) + manager.data = data.EnergyManager.default_preferences() + manager.data["energy_sources"].append( + { + "type": "solar", + "stat_energy_from": "my_solar_production", + "config_entry_solar_forecast": [entry.entry_id], + } + ) + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "energy/solar_forecast"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == { + entry.entry_id: { + "wh_hours": { + "2021-06-27T13:00:00+00:00": 12, + "2021-06-27T14:00:00+00:00": 8, + } + } + } diff --git a/tests/components/epson/test_config_flow.py b/tests/components/epson/test_config_flow.py index 3ff7753d3eb..9c02feadc1a 100644 --- a/tests/components/epson/test_config_flow.py +++ b/tests/components/epson/test_config_flow.py @@ -7,8 +7,6 @@ from homeassistant import config_entries, setup from homeassistant.components.epson.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, STATE_UNAVAILABLE -from tests.common import MockConfigEntry - async def test_form(hass): """Test we get the form.""" @@ -75,70 +73,3 @@ async def test_form_powered_off(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "powered_off"} - - -async def test_import(hass): - """Test config.yaml import.""" - with patch( - "homeassistant.components.epson.Projector.get_power", - return_value="01", - ), patch( - "homeassistant.components.epson.Projector.get_property", - return_value="04", - ), patch( - "homeassistant.components.epson.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_NAME: "test-epson"}, - ) - assert result["type"] == "create_entry" - assert result["title"] == "test-epson" - assert result["data"] == {CONF_HOST: "1.1.1.1"} - - -async def test_already_imported(hass): - """Test config.yaml imported twice.""" - MockConfigEntry( - domain=DOMAIN, - source=config_entries.SOURCE_IMPORT, - unique_id="bla", - title="test-epson", - data={CONF_HOST: "1.1.1.1"}, - ).add_to_hass(hass) - - with patch( - "homeassistant.components.epson.Projector.get_power", - return_value="01", - ), patch( - "homeassistant.components.epson.Projector.get_property", - return_value="04", - ), patch( - "homeassistant.components.epson.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_NAME: "test-epson"}, - ) - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - -async def test_import_cannot_connect(hass): - """Test we handle cannot connect error.""" - with patch( - "homeassistant.components.epson.Projector.get_power", - return_value=STATE_UNAVAILABLE, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_NAME: "test-epson"}, - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" diff --git a/tests/components/fail2ban/test_sensor.py b/tests/components/fail2ban/test_sensor.py index f9c78e14888..0240ffc6d11 100644 --- a/tests/components/fail2ban/test_sensor.py +++ b/tests/components/fail2ban/test_sensor.py @@ -83,6 +83,7 @@ async def test_single_ban(hass): """Test that log is parsed correctly for single ban.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("single_ban")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -97,6 +98,7 @@ async def test_ipv6_ban(hass): """Test that log is parsed correctly for IPV6 bans.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("ipv6_ban")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -111,6 +113,7 @@ async def test_multiple_ban(hass): """Test that log is parsed correctly for multiple ban.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("multi_ban")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -131,6 +134,7 @@ async def test_unban_all(hass): """Test that log is parsed correctly when unbanning.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("unban_all")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -148,6 +152,7 @@ async def test_unban_one(hass): """Test that log is parsed correctly when unbanning one ip.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("unban_one")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): @@ -166,6 +171,8 @@ async def test_multi_jail(hass): log_parser = BanLogParser("/test/fail2ban.log") sensor1 = BanSensor("fail2ban", "jail_one", log_parser) sensor2 = BanSensor("fail2ban", "jail_two", log_parser) + sensor1.hass = hass + sensor2.hass = hass assert sensor1.name == "fail2ban jail_one" assert sensor2.name == "fail2ban jail_two" mock_fh = mock_open(read_data=fake_log("multi_jail")) @@ -185,6 +192,7 @@ async def test_ban_active_after_update(hass): """Test that ban persists after subsequent update.""" log_parser = BanLogParser("/test/fail2ban.log") sensor = BanSensor("fail2ban", "jail_one", log_parser) + sensor.hass = hass assert sensor.name == "fail2ban jail_one" mock_fh = mock_open(read_data=fake_log("single_ban")) with patch("homeassistant.components.fail2ban.sensor.open", mock_fh, create=True): diff --git a/tests/components/ffmpeg/test_init.py b/tests/components/ffmpeg/test_init.py index 3c6a2fbb92d..e1730ffdabb 100644 --- a/tests/components/ffmpeg/test_init.py +++ b/tests/components/ffmpeg/test_init.py @@ -1,7 +1,7 @@ """The tests for Home Assistant ffmpeg.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch -import homeassistant.components.ffmpeg as ffmpeg +from homeassistant.components import ffmpeg from homeassistant.components.ffmpeg import ( DOMAIN, SERVICE_RESTART, @@ -181,3 +181,58 @@ async def test_setup_component_test_service_start_with_entity(hass): assert ffmpeg_dev.called_start assert ffmpeg_dev.called_entities == ["test.ffmpeg_device"] + + +async def test_async_get_image_with_width_height(hass): + """Test fetching an image with a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image(hass, "rtsp://fake", width=640, height=480) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-s 640x480") + ] + + +async def test_async_get_image_with_extra_cmd_overlapping_width_height(hass): + """Test fetching an image with and extra_cmd with width and height and a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image( + hass, "rtsp://fake", extra_cmd="-s 1024x768", width=640, height=480 + ) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-s 1024x768") + ] + + +async def test_async_get_image_with_extra_cmd_width_height(hass): + """Test fetching an image with and extra_cmd and a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image( + hass, "rtsp://fake", extra_cmd="-vf any", width=640, height=480 + ) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-vf any -s 640x480") + ] diff --git a/tests/components/fido/test_sensor.py b/tests/components/fido/test_sensor.py index 3baaf2e350c..bcece50f6e4 100644 --- a/tests/components/fido/test_sensor.py +++ b/tests/components/fido/test_sensor.py @@ -66,7 +66,13 @@ async def test_error(hass, caplog): """Test the Fido sensor errors.""" caplog.set_level(logging.ERROR) - config = {} + config = { + "platform": "fido", + "name": "fido", + "username": "myusername", + "password": "password", + "monitored_variables": ["balance", "data_remaining"], + } fake_async_add_entities = MagicMock() with patch("homeassistant.components.fido.sensor.FidoClient", FidoClientMockError): await fido.async_setup_platform(hass, config, fake_async_add_entities) diff --git a/tests/components/fjaraskupan/__init__.py b/tests/components/fjaraskupan/__init__.py new file mode 100644 index 00000000000..26a5ecd6605 --- /dev/null +++ b/tests/components/fjaraskupan/__init__.py @@ -0,0 +1 @@ +"""Tests for the Fjäråskupan integration.""" diff --git a/tests/components/fjaraskupan/conftest.py b/tests/components/fjaraskupan/conftest.py new file mode 100644 index 00000000000..d60abcdb9ad --- /dev/null +++ b/tests/components/fjaraskupan/conftest.py @@ -0,0 +1,41 @@ +"""Standard fixtures for the Fjäråskupan integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData, BaseBleakScanner +from pytest import fixture + + +@fixture(name="scanner", autouse=True) +def fixture_scanner(hass): + """Fixture for scanner.""" + + devices = [BLEDevice("1.1.1.1", "COOKERHOOD_FJAR")] + + class MockScanner(BaseBleakScanner): + """Mock Scanner.""" + + async def start(self): + """Start scanning for devices.""" + for device in devices: + self._callback(device, AdvertisementData()) + + async def stop(self): + """Stop scanning for devices.""" + + @property + def discovered_devices(self) -> list[BLEDevice]: + """Return discovered devices.""" + return devices + + def set_scanning_filter(self, **kwargs): + """Set the scanning filter.""" + + with patch( + "homeassistant.components.fjaraskupan.config_flow.BleakScanner", new=MockScanner + ), patch( + "homeassistant.components.fjaraskupan.config_flow.CONST_WAIT_TIME", new=0.01 + ): + yield devices diff --git a/tests/components/fjaraskupan/test_config_flow.py b/tests/components/fjaraskupan/test_config_flow.py new file mode 100644 index 00000000000..7244042d356 --- /dev/null +++ b/tests/components/fjaraskupan/test_config_flow.py @@ -0,0 +1,59 @@ +"""Test the Fjäråskupan config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from bleak.backends.device import BLEDevice +from pytest import fixture + +from homeassistant import config_entries, setup +from homeassistant.components.fjaraskupan.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + + +@fixture(name="mock_setup_entry", autouse=True) +async def fixture_mock_setup_entry(hass): + """Fixture for config entry.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.fjaraskupan.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_configure(hass: HomeAssistant, mock_setup_entry) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Fjäråskupan" + assert result["data"] == {} + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_scan_no_devices(hass: HomeAssistant, scanner: list[BLEDevice]) -> None: + """Test we get the form.""" + scanner.clear() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 8b9227a8d04..0bf080535f6 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -61,6 +61,7 @@ def mock_forecast_solar() -> Generator[None, MagicMock, None]: estimate = MagicMock(spec=models.Estimate) estimate.now.return_value = now estimate.timezone = "Europe/Amsterdam" + estimate.account_type.value = "public" estimate.energy_production_today = 100000 estimate.energy_production_tomorrow = 200000 estimate.power_production_now = 300000 diff --git a/tests/components/forecast_solar/test_energy.py b/tests/components/forecast_solar/test_energy.py new file mode 100644 index 00000000000..9ab6038818b --- /dev/null +++ b/tests/components/forecast_solar/test_energy.py @@ -0,0 +1,34 @@ +"""Test forecast solar energy platform.""" +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from homeassistant.components.forecast_solar import energy +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_energy_solar_forecast( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_forecast_solar: MagicMock, +) -> None: + """Test the Forecast.Solar energy platform solar forecast.""" + mock_forecast_solar.estimate.return_value.wh_hours = { + datetime(2021, 6, 27, 13, 0, tzinfo=timezone.utc): 12, + datetime(2021, 6, 27, 14, 0, tzinfo=timezone.utc): 8, + } + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.LOADED + + assert await energy.async_get_solar_forecast(hass, mock_config_entry.entry_id) == { + "wh_hours": { + "2021-06-27T13:00:00+00:00": 12, + "2021-06-27T14:00:00+00:00": 8, + } + } diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 453196e3300..a0a8f802e5a 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -1,5 +1,4 @@ """Tests for the Forecast.Solar integration.""" -from datetime import datetime, timezone from unittest.mock import MagicMock, patch from forecast_solar import ForecastSolarConnectionError @@ -7,6 +6,7 @@ from forecast_solar import ForecastSolarConnectionError from homeassistant.components.forecast_solar.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -15,39 +15,13 @@ async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_forecast_solar: MagicMock, - hass_ws_client, ) -> None: """Test the Forecast.Solar configuration entry loading/unloading.""" - mock_forecast_solar.estimate.return_value.wh_hours = { - datetime(2021, 6, 27, 13, 0, tzinfo=timezone.utc): 12, - datetime(2021, 6, 27, 14, 0, tzinfo=timezone.utc): 8, - } - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_component(hass, "forecast_solar", {}) assert mock_config_entry.state == ConfigEntryState.LOADED - # Test WS API set up - client = await hass_ws_client(hass) - await client.send_json( - { - "id": 5, - "type": "forecast_solar/forecasts", - } - ) - result = await client.receive_json() - assert result["success"] - assert result["result"] == { - mock_config_entry.entry_id: { - "wh_hours": { - "2021-06-27T13:00:00+00:00": 12, - "2021-06-27T14:00:00+00:00": 8, - } - } - } - await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index a2b105ccbd1..6c910d699c4 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -142,7 +142,7 @@ async def test_sensors( assert device_entry.manufacturer == "Forecast.Solar" assert device_entry.name == "Solar Production Forecast" assert device_entry.entry_type == ENTRY_TYPE_SERVICE - assert not device_entry.model + assert device_entry.model == "public" assert not device_entry.sw_version diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 951528f1e7d..27461b2790f 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -10,10 +10,10 @@ from homeassistant.components.fritzbox.const import ( DOMAIN as FB_DOMAIN, ) from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, ) from homeassistant.components.switch import DOMAIN from homeassistant.const import ( @@ -73,10 +73,9 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_total_energy") assert state assert state.state == "1.234" - assert state.attributes[ATTR_LAST_RESET] == "1970-01-01T00:00:00+00:00" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Total Energy" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING async def test_turn_on(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 2da3d8e1e8c..adf151f4819 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -18,9 +18,17 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CO, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_SULPHUR_DIOXIDE, STATE_UNAVAILABLE, ) from homeassistant.helpers import entity_registry as er @@ -45,7 +53,7 @@ async def test_sensor(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_ICON) == "mdi:molecule" assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" entry = registry.async_get("sensor.home_c6h6") @@ -57,12 +65,12 @@ async def test_sensor(hass): assert state.state == "252" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CO assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_co") @@ -74,12 +82,12 @@ async def test_sensor(hass): assert state.state == "7" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_NITROGEN_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_no2") @@ -91,12 +99,12 @@ async def test_sensor(hass): assert state.state == "96" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_OZONE assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_o3") @@ -108,12 +116,12 @@ async def test_sensor(hass): assert state.state == "17" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_pm10") @@ -125,12 +133,12 @@ async def test_sensor(hass): assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "dobry" entry = registry.async_get("sensor.home_pm2_5") @@ -142,12 +150,12 @@ async def test_sensor(hass): assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SULPHUR_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" entry = registry.async_get("sensor.home_so2") @@ -159,9 +167,9 @@ async def test_sensor(hass): assert state.state == "dobry" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AQI assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get("sensor.home_aqi") assert entry @@ -225,7 +233,7 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_ICON) == "mdi:molecule" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_c6h6") @@ -242,7 +250,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_co") @@ -259,7 +266,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_no2") @@ -276,7 +282,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_o3") @@ -293,7 +298,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_pm10") @@ -310,7 +314,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_pm2_5") @@ -327,7 +330,6 @@ async def test_invalid_indexes(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" assert state.attributes.get(ATTR_INDEX) is None entry = registry.async_get("sensor.home_so2") diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index c57d894c36d..290aa00bb47 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -34,6 +34,7 @@ from homeassistant.components.media_player.const import ( from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, + ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_MODE, @@ -356,6 +357,105 @@ async def test_dock_vacuum(hass): assert calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} +async def test_locate_vacuum(hass): + """Test locate trait support for vacuum domain.""" + assert helpers.get_google_type(vacuum.DOMAIN, None) is not None + assert trait.LocatorTrait.supported( + vacuum.DOMAIN, vacuum.SUPPORT_LOCATE, None, None + ) + + trt = trait.LocatorTrait( + hass, + State( + "vacuum.bla", + vacuum.STATE_IDLE, + {ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_LOCATE}, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {} + + calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_LOCATE) + await trt.execute(trait.COMMAND_LOCATE, BASIC_DATA, {"silence": False}, {}) + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute(trait.COMMAND_LOCATE, BASIC_DATA, {"silence": True}, {}) + assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED + + +async def test_energystorage_vacuum(hass): + """Test EnergyStorage trait support for vacuum domain.""" + assert helpers.get_google_type(vacuum.DOMAIN, None) is not None + assert trait.EnergyStorageTrait.supported( + vacuum.DOMAIN, vacuum.SUPPORT_BATTERY, None, None + ) + + trt = trait.EnergyStorageTrait( + hass, + State( + "vacuum.bla", + vacuum.STATE_DOCKED, + { + ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_BATTERY, + ATTR_BATTERY_LEVEL: 100, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "isRechargeable": True, + "queryOnlyEnergyStorage": True, + } + + assert trt.query_attributes() == { + "descriptiveCapacityRemaining": "FULL", + "capacityRemaining": [{"rawValue": 100, "unit": "PERCENTAGE"}], + "capacityUntilFull": [{"rawValue": 0, "unit": "PERCENTAGE"}], + "isCharging": True, + "isPluggedIn": True, + } + + trt = trait.EnergyStorageTrait( + hass, + State( + "vacuum.bla", + vacuum.STATE_CLEANING, + { + ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_BATTERY, + ATTR_BATTERY_LEVEL: 20, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "isRechargeable": True, + "queryOnlyEnergyStorage": True, + } + + assert trt.query_attributes() == { + "descriptiveCapacityRemaining": "CRITICALLY_LOW", + "capacityRemaining": [{"rawValue": 20, "unit": "PERCENTAGE"}], + "capacityUntilFull": [{"rawValue": 80, "unit": "PERCENTAGE"}], + "isCharging": False, + "isPluggedIn": False, + } + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute(trait.COMMAND_CHARGE, BASIC_DATA, {"charge": True}, {}) + assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute(trait.COMMAND_CHARGE, BASIC_DATA, {"charge": False}, {}) + assert err.value.code == const.ERR_FUNCTION_NOT_SUPPORTED + + async def test_startstop_vacuum(hass): """Test startStop trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None @@ -476,6 +576,7 @@ async def test_color_setting_color_light(hass, supported_color_modes): { light.ATTR_HS_COLOR: (20, 94), light.ATTR_BRIGHTNESS: 200, + light.ATTR_COLOR_MODE: "hs", "supported_color_modes": supported_color_modes, }, ), @@ -534,6 +635,7 @@ async def test_color_setting_temperature_light(hass): STATE_ON, { light.ATTR_MIN_MIREDS: 200, + light.ATTR_COLOR_MODE: "color_temp", light.ATTR_COLOR_TEMP: 300, light.ATTR_MAX_MIREDS: 500, "supported_color_modes": ["color_temp"], @@ -2901,3 +3003,56 @@ async def test_channel(hass): with pytest.raises(SmartHomeError, match="Unsupported command"): await trt.execute("Unknown command", BASIC_DATA, {"channelNumber": "1"}, {}) assert len(media_player_calls) == 1 + + +async def test_sensorstate(hass): + """Test SensorState trait support for sensor domain.""" + sensor_types = { + sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), + sensor.DEVICE_CLASS_CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + "VolatileOrganicCompounds", + "PARTS_PER_MILLION", + ), + } + + for sensor_type in sensor_types: + assert helpers.get_google_type(sensor.DOMAIN, None) is not None + assert trait.SensorStateTrait.supported(sensor.DOMAIN, None, sensor_type, None) + + trt = trait.SensorStateTrait( + hass, + State( + "sensor.test", + 100.0, + { + "device_class": sensor_type, + }, + ), + BASIC_CONFIG, + ) + + name = sensor_types[sensor_type][0] + unit = sensor_types[sensor_type][1] + + assert trt.sync_attributes() == { + "sensorStatesSupported": { + "name": name, + "numericCapabilities": {"rawValueUnit": unit}, + } + } + + assert trt.query_attributes() == { + "currentSensorStateData": [{"name": name, "rawValue": "100.0"}] + } + + assert helpers.get_google_type(sensor.DOMAIN, None) is not None + assert ( + trait.SensorStateTrait.supported( + sensor.DOMAIN, None, sensor.DEVICE_CLASS_MONETARY, None + ) + is False + ) diff --git a/tests/components/google_pubsub/test_init.py b/tests/components/google_pubsub/test_init.py index fc1fecb04ed..d31d28e7302 100644 --- a/tests/components/google_pubsub/test_init.py +++ b/tests/components/google_pubsub/test_init.py @@ -1,6 +1,7 @@ """The tests for the Google Pub/Sub component.""" from dataclasses import dataclass from datetime import datetime +import os import unittest.mock as mock import pytest @@ -51,13 +52,12 @@ def mock_client_fixture(): yield client -@pytest.fixture(autouse=True, name="mock_os") -def mock_os_fixture(): - """Mock the OS cli.""" - with mock.patch(f"{GOOGLE_PUBSUB_PATH}.os") as os_cli: - os_cli.path = mock.MagicMock() - setattr(os_cli.path, "join", mock.MagicMock(return_value="path")) - yield os_cli +@pytest.fixture(autouse=True, name="mock_is_file") +def mock_is_file_fixture(): + """Mock os.path.isfile.""" + with mock.patch(f"{GOOGLE_PUBSUB_PATH}.os.path.isfile") as is_file: + is_file.return_value = True + yield is_file @pytest.fixture(autouse=True) @@ -84,9 +84,9 @@ async def test_minimal_config(hass, mock_client): assert hass.bus.listen.called assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert mock_client.PublisherClient.from_service_account_json.call_count == 1 - assert ( - mock_client.PublisherClient.from_service_account_json.call_args[0][0] == "path" - ) + assert mock_client.PublisherClient.from_service_account_json.call_args[0][ + 0 + ] == os.path.join(hass.config.config_dir, "creds") async def test_full_config(hass, mock_client): @@ -111,9 +111,9 @@ async def test_full_config(hass, mock_client): assert hass.bus.listen.called assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert mock_client.PublisherClient.from_service_account_json.call_count == 1 - assert ( - mock_client.PublisherClient.from_service_account_json.call_args[0][0] == "path" - ) + assert mock_client.PublisherClient.from_service_account_json.call_args[0][ + 0 + ] == os.path.join(hass.config.config_dir, "creds") def make_event(entity_id): diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 6f4b4652e76..59c95d9883b 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -68,22 +68,26 @@ async def test_setup_get(hass, requests_mock): assert_setup_component(6, "sensor") -def setup_api(data, requests_mock): +def setup_api(hass, data, requests_mock): """Set up API with fake data.""" resource = f"http://localhost{google_wifi.ENDPOINT}" now = datetime(1970, month=1, day=1) sensor_dict = {} with patch("homeassistant.util.dt.now", return_value=now): requests_mock.get(resource, text=data, status_code=200) - conditions = google_wifi.MONITORED_CONDITIONS.keys() + conditions = google_wifi.SENSOR_KEYS api = google_wifi.GoogleWifiAPI("localhost", conditions) - for condition, cond_list in google_wifi.MONITORED_CONDITIONS.items(): - sensor_dict[condition] = { - "sensor": google_wifi.GoogleWifiSensor(api, NAME, condition), - "name": f"{NAME}_{condition}", - "units": cond_list[1], - "icon": cond_list[2], + for desc in google_wifi.SENSOR_TYPES: + sensor_dict[desc.key] = { + "sensor": google_wifi.GoogleWifiSensor(api, NAME, desc), + "name": f"{NAME}_{desc.key}", + "units": desc.native_unit_of_measurement, + "icon": desc.icon, } + for name in sensor_dict: + sensor = sensor_dict[name]["sensor"] + sensor.hass = hass + return api, sensor_dict @@ -96,7 +100,7 @@ def fake_delay(hass, ha_delay): def test_name(requests_mock): """Test the name.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] test_name = sensor_dict[name]["name"] @@ -105,7 +109,7 @@ def test_name(requests_mock): def test_unit_of_measurement(requests_mock): """Test the unit of measurement.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] assert sensor_dict[name]["units"] == sensor.unit_of_measurement @@ -113,7 +117,7 @@ def test_unit_of_measurement(requests_mock): def test_icon(requests_mock): """Test the icon.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] assert sensor_dict[name]["icon"] == sensor.icon @@ -121,7 +125,7 @@ def test_icon(requests_mock): def test_state(hass, requests_mock): """Test the initial state.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name in sensor_dict: @@ -140,7 +144,7 @@ def test_state(hass, requests_mock): def test_update_when_value_is_none(hass, requests_mock): """Test state gets updated to unknown when sensor returns no data.""" - api, sensor_dict = setup_api(None, requests_mock) + api, sensor_dict = setup_api(hass, None, requests_mock) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] fake_delay(hass, 2) @@ -150,7 +154,7 @@ def test_update_when_value_is_none(hass, requests_mock): def test_update_when_value_changed(hass, requests_mock): """Test state gets updated when sensor returns a new status.""" - api, sensor_dict = setup_api(MOCK_DATA_NEXT, requests_mock) + api, sensor_dict = setup_api(hass, MOCK_DATA_NEXT, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name in sensor_dict: @@ -173,7 +177,7 @@ def test_update_when_value_changed(hass, requests_mock): def test_when_api_data_missing(hass, requests_mock): """Test state logs an error when data is missing.""" - api, sensor_dict = setup_api(MOCK_DATA_MISSING, requests_mock) + api, sensor_dict = setup_api(hass, MOCK_DATA_MISSING, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name in sensor_dict: @@ -183,12 +187,12 @@ def test_when_api_data_missing(hass, requests_mock): assert sensor.state == STATE_UNKNOWN -def test_update_when_unavailable(requests_mock): +def test_update_when_unavailable(hass, requests_mock): """Test state updates when Google Wifi unavailable.""" - api, sensor_dict = setup_api(None, requests_mock) + api, sensor_dict = setup_api(hass, None, requests_mock) api.update = Mock( "google_wifi.GoogleWifiAPI.update", - side_effect=update_side_effect(requests_mock), + side_effect=update_side_effect(hass, requests_mock), ) for name in sensor_dict: sensor = sensor_dict[name]["sensor"] @@ -196,8 +200,8 @@ def test_update_when_unavailable(requests_mock): assert sensor.state is None -def update_side_effect(requests_mock): +def update_side_effect(hass, requests_mock): """Mock representation of update function.""" - api, sensor_dict = setup_api(MOCK_DATA, requests_mock) + api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) api.data = None api.available = False diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 8a29274298b..758bc5e0dac 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -177,7 +177,7 @@ async def test_attributes(hass, setup_comp): assert state.state == STATE_OPEN assert state.attributes[ATTR_ASSUMED_STATE] is True assert state.attributes[ATTR_SUPPORTED_FEATURES] == 244 - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + assert state.attributes[ATTR_CURRENT_POSITION] == 85 # (70 + 100) / 2 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 hass.states.async_remove(DEMO_COVER) @@ -204,7 +204,7 @@ async def test_attributes(hass, setup_comp): assert state.attributes[ATTR_ASSUMED_STATE] is True assert state.attributes[ATTR_SUPPORTED_FEATURES] == 128 assert ATTR_CURRENT_POSITION not in state.attributes - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 80 # (60 + 100) / 2 hass.states.async_remove(DEMO_COVER_TILT) hass.states.async_set(DEMO_TILT, STATE_CLOSED) @@ -367,8 +367,8 @@ async def test_stop_covers(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + assert state.state == STATE_OPENING + assert state.attributes[ATTR_CURRENT_POSITION] == 50 # (20 + 80) / 2 assert hass.states.get(DEMO_COVER).state == STATE_OPEN assert hass.states.get(DEMO_COVER_POS).attributes[ATTR_CURRENT_POSITION] == 20 @@ -542,6 +542,7 @@ async def test_is_opening_closing(hass, setup_comp): ) await hass.async_block_till_done() + # Both covers opening -> opening assert hass.states.get(DEMO_COVER_POS).state == STATE_OPENING assert hass.states.get(DEMO_COVER_TILT).state == STATE_OPENING assert hass.states.get(COVER_GROUP).state == STATE_OPENING @@ -555,6 +556,7 @@ async def test_is_opening_closing(hass, setup_comp): DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) + # Both covers closing -> closing assert hass.states.get(DEMO_COVER_POS).state == STATE_CLOSING assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING assert hass.states.get(COVER_GROUP).state == STATE_CLOSING @@ -562,11 +564,44 @@ async def test_is_opening_closing(hass, setup_comp): hass.states.async_set(DEMO_COVER_POS, STATE_OPENING, {ATTR_SUPPORTED_FEATURES: 11}) await hass.async_block_till_done() + # Closing + Opening -> Opening + assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING assert hass.states.get(DEMO_COVER_POS).state == STATE_OPENING assert hass.states.get(COVER_GROUP).state == STATE_OPENING hass.states.async_set(DEMO_COVER_POS, STATE_CLOSING, {ATTR_SUPPORTED_FEATURES: 11}) await hass.async_block_till_done() + # Both covers closing -> closing + assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING assert hass.states.get(DEMO_COVER_POS).state == STATE_CLOSING assert hass.states.get(COVER_GROUP).state == STATE_CLOSING + + # Closed + Closing -> Closing + hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {ATTR_SUPPORTED_FEATURES: 11}) + await hass.async_block_till_done() + assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING + assert hass.states.get(DEMO_COVER_POS).state == STATE_CLOSED + assert hass.states.get(COVER_GROUP).state == STATE_CLOSING + + # Open + Closing -> Closing + hass.states.async_set(DEMO_COVER_POS, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 11}) + await hass.async_block_till_done() + assert hass.states.get(DEMO_COVER_TILT).state == STATE_CLOSING + assert hass.states.get(DEMO_COVER_POS).state == STATE_OPEN + assert hass.states.get(COVER_GROUP).state == STATE_CLOSING + + # Closed + Opening -> Closing + hass.states.async_set(DEMO_COVER_TILT, STATE_OPENING, {ATTR_SUPPORTED_FEATURES: 11}) + hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {ATTR_SUPPORTED_FEATURES: 11}) + await hass.async_block_till_done() + assert hass.states.get(DEMO_COVER_TILT).state == STATE_OPENING + assert hass.states.get(DEMO_COVER_POS).state == STATE_CLOSED + assert hass.states.get(COVER_GROUP).state == STATE_OPENING + + # Open + Opening -> Closing + hass.states.async_set(DEMO_COVER_POS, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 11}) + await hass.async_block_till_done() + assert hass.states.get(DEMO_COVER_TILT).state == STATE_OPENING + assert hass.states.get(DEMO_COVER_POS).state == STATE_OPEN + assert hass.states.get(COVER_GROUP).state == STATE_OPENING diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 74275cf0bd2..e769bf33f8a 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -21,6 +21,7 @@ from homeassistant.components.light import ( ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_WHITE, ATTR_WHITE_VALUE, ATTR_XY_COLOR, COLOR_MODE_BRIGHTNESS, @@ -29,6 +30,7 @@ from homeassistant.components.light import ( COLOR_MODE_ONOFF, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, + COLOR_MODE_WHITE, DOMAIN as LIGHT_DOMAIN, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -442,6 +444,62 @@ async def test_white_value(hass): assert state.attributes[ATTR_WHITE_VALUE] == 100 +async def test_white(hass, enable_custom_integrations): + """Test white reporting.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("test1", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("test2", STATE_ON)) + + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = {COLOR_MODE_HS, COLOR_MODE_WHITE} + entity0.color_mode = COLOR_MODE_WHITE + entity0.brightness = 255 + + entity1 = platform.ENTITIES[1] + entity1.supported_color_modes = {COLOR_MODE_HS, COLOR_MODE_WHITE} + entity1.color_mode = COLOR_MODE_WHITE + entity1.brightness = 128 + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "test"}, + { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes[ATTR_COLOR_MODE] == "white" + assert state.attributes[ATTR_BRIGHTNESS] == 191 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs", "white"] + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": ["light.light_group"], ATTR_WHITE: 128}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes[ATTR_COLOR_MODE] == "white" + assert state.attributes[ATTR_BRIGHTNESS] == 128 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs", "white"] + + async def test_color_temp(hass, enable_custom_integrations): """Test color temp reporting.""" platform = getattr(hass.components, "test.light") @@ -560,12 +618,12 @@ async def test_emulated_color_temp_group(hass, enable_custom_integrations): state = hass.states.get("light.test1") assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_TEMP] == 200 - assert ATTR_HS_COLOR not in state.attributes.keys() + assert ATTR_HS_COLOR in state.attributes.keys() state = hass.states.get("light.test2") assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_TEMP] == 200 - assert ATTR_HS_COLOR not in state.attributes.keys() + assert ATTR_HS_COLOR in state.attributes.keys() state = hass.states.get("light.test3") assert state.state == STATE_ON diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index 662448c8118..096052fd6cf 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -149,30 +149,6 @@ async def test_one_plant_on_account(hass): assert result["data"][CONF_PLANT_ID] == "123456" -async def test_import_one_plant(hass): - """Test import step with a single plant.""" - import_data = FIXTURE_USER_INPUT.copy() - - with patch( - "growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE - ), patch( - "growattServer.GrowattApi.plant_list", - return_value=GROWATT_PLANT_LIST_RESPONSE, - ), patch( - "homeassistant.components.growatt_server.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=import_data, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] - assert result["data"][CONF_PLANT_ID] == "123456" - - async def test_existing_plant_configured(hass): """Test entering an existing plant_id.""" entry = MockConfigEntry(domain=DOMAIN, unique_id="123456") diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index ff1c348a37b..16121393170 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -1,7 +1,7 @@ """The tests for the hassio component.""" import asyncio -from unittest.mock import patch +from aiohttp import StreamReader import pytest from homeassistant.components.hassio.http import _need_auth @@ -106,13 +106,11 @@ async def test_forward_log_request(hassio_client, aioclient_mock): assert len(aioclient_mock.mock_calls) == 1 -async def test_bad_gateway_when_cannot_find_supervisor(hassio_client): +async def test_bad_gateway_when_cannot_find_supervisor(hassio_client, aioclient_mock): """Test we get a bad gateway error if we can't find supervisor.""" - with patch( - "homeassistant.components.hassio.http.async_timeout.timeout", - side_effect=asyncio.TimeoutError, - ): - resp = await hassio_client.get("/api/hassio/addons/test/info") + aioclient_mock.get("http://127.0.0.1/addons/test/info", exc=asyncio.TimeoutError) + + resp = await hassio_client.get("/api/hassio/addons/test/info") assert resp.status == 502 @@ -132,13 +130,13 @@ async def test_forwarding_user_info(hassio_client, hass_admin_user, aioclient_mo assert req_headers["X-Hass-Is-Admin"] == "1" -async def test_snapshot_upload_headers(hassio_client, aioclient_mock): - """Test that we forward the full header for snapshot upload.""" +async def test_backup_upload_headers(hassio_client, aioclient_mock, caplog): + """Test that we forward the full header for backup upload.""" content_type = "multipart/form-data; boundary='--webkit'" - aioclient_mock.get("http://127.0.0.1/snapshots/new/upload") + aioclient_mock.get("http://127.0.0.1/backups/new/upload") resp = await hassio_client.get( - "/api/hassio/snapshots/new/upload", headers={"Content-Type": content_type} + "/api/hassio/backups/new/upload", headers={"Content-Type": content_type} ) # Check we got right response @@ -150,18 +148,18 @@ async def test_snapshot_upload_headers(hassio_client, aioclient_mock): assert req_headers["Content-Type"] == content_type -async def test_snapshot_download_headers(hassio_client, aioclient_mock): - """Test that we forward the full header for snapshot download.""" +async def test_backup_download_headers(hassio_client, aioclient_mock): + """Test that we forward the full header for backup download.""" content_disposition = "attachment; filename=test.tar" aioclient_mock.get( - "http://127.0.0.1/snapshots/slug/download", + "http://127.0.0.1/backups/slug/download", headers={ "Content-Length": "50000000", "Content-Disposition": content_disposition, }, ) - resp = await hassio_client.get("/api/hassio/snapshots/slug/download") + resp = await hassio_client.get("/api/hassio/backups/slug/download") # Check we got right response assert resp.status == 200 @@ -174,9 +172,34 @@ async def test_snapshot_download_headers(hassio_client, aioclient_mock): def test_need_auth(hass): """Test if the requested path needs authentication.""" assert not _need_auth(hass, "addons/test/logo") - assert _need_auth(hass, "snapshots/new/upload") + assert _need_auth(hass, "backups/new/upload") assert _need_auth(hass, "supervisor/logs") hass.data["onboarding"] = False - assert not _need_auth(hass, "snapshots/new/upload") + assert not _need_auth(hass, "backups/new/upload") assert not _need_auth(hass, "supervisor/logs") + + +async def test_stream(hassio_client, aioclient_mock): + """Verify that the request is a stream.""" + aioclient_mock.get("http://127.0.0.1/test") + await hassio_client.get("/api/hassio/test", data="test") + assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) + + +async def test_entrypoint_cache_control(hassio_client, aioclient_mock): + """Test that we return cache control for requests to the entrypoint only.""" + aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") + aioclient_mock.get("http://127.0.0.1/app/entrypoint.fdhkusd8y43r.js") + + resp1 = await hassio_client.get("/api/hassio/app/entrypoint.js") + resp2 = await hassio_client.get("/api/hassio/app/entrypoint.fdhkusd8y43r.js") + + # Check we got right response + assert resp1.status == 200 + assert resp2.status == 200 + + assert len(aioclient_mock.mock_calls) == 2 + assert resp1.headers["Cache-Control"] == "no-store, max-age=0" + + assert "Cache-Control" not in resp2.headers diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 8377e5287d0..5af9908de3a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -303,11 +303,13 @@ async def test_service_register(hassio_env, hass): assert hass.services.has_service("hassio", "host_reboot") assert hass.services.has_service("hassio", "snapshot_full") assert hass.services.has_service("hassio", "snapshot_partial") + assert hass.services.has_service("hassio", "backup_full") + assert hass.services.has_service("hassio", "backup_partial") assert hass.services.has_service("hassio", "restore_full") assert hass.services.has_service("hassio", "restore_partial") -async def test_service_calls(hassio_env, hass, aioclient_mock): +async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): """Call service and check the API calls behind that.""" assert await async_setup_component(hass, "hassio", {}) @@ -318,13 +320,13 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): aioclient_mock.post("http://127.0.0.1/addons/test/stdin", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/host/shutdown", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/host/reboot", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/snapshots/new/full", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/snapshots/new/partial", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/backups/new/full", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/backups/new/partial", json={"result": "ok"}) aioclient_mock.post( - "http://127.0.0.1/snapshots/test/restore/full", json={"result": "ok"} + "http://127.0.0.1/backups/test/restore/full", json={"result": "ok"} ) aioclient_mock.post( - "http://127.0.0.1/snapshots/test/restore/partial", json={"result": "ok"} + "http://127.0.0.1/backups/test/restore/partial", json={"result": "ok"} ) await hass.services.async_call("hassio", "addon_start", {"addon": "test"}) @@ -345,27 +347,48 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): assert aioclient_mock.call_count == 10 + await hass.services.async_call("hassio", "backup_full", {}) + await hass.services.async_call( + "hassio", + "backup_partial", + {"addons": ["test"], "folders": ["ssl"], "password": "123456"}, + ) await hass.services.async_call("hassio", "snapshot_full", {}) await hass.services.async_call( "hassio", "snapshot_partial", - {"addons": ["test"], "folders": ["ssl"], "password": "123456"}, + {"addons": ["test"], "folders": ["ssl"]}, ) await hass.async_block_till_done() + assert ( + "The service 'snapshot_full' is deprecated and will be removed in Home Assistant 2021.11, use 'backup_full' instead" + in caplog.text + ) + assert ( + "The service 'snapshot_partial' is deprecated and will be removed in Home Assistant 2021.11, use 'backup_partial' instead" + in caplog.text + ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[-1][2] == { + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[-3][2] == { "addons": ["test"], "folders": ["ssl"], "password": "123456", } + await hass.services.async_call("hassio", "restore_full", {"slug": "test"}) await hass.services.async_call("hassio", "restore_full", {"snapshot": "test"}) + await hass.async_block_till_done() + assert ( + "Using 'snapshot' is deprecated and will be removed in Home Assistant 2021.11, use 'slug' instead" + in caplog.text + ) + await hass.services.async_call( "hassio", "restore_partial", { - "snapshot": "test", + "slug": "test", "homeassistant": False, "addons": ["test"], "folders": ["ssl"], @@ -374,7 +397,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 14 + assert aioclient_mock.call_count == 17 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 5278d2cbb91..5578194b87c 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -61,7 +61,7 @@ async def test_websocket_supervisor_api( assert await async_setup_component(hass, "hassio", {}) websocket_client = await hass_ws_client(hass) aioclient_mock.post( - "http://127.0.0.1/snapshots/new/partial", + "http://127.0.0.1/backups/new/partial", json={"result": "ok", "data": {"slug": "sn_slug"}}, ) @@ -69,7 +69,7 @@ async def test_websocket_supervisor_api( { WS_ID: 1, WS_TYPE: WS_TYPE_API, - ATTR_ENDPOINT: "/snapshots/new/partial", + ATTR_ENDPOINT: "/backups/new/partial", ATTR_METHOD: "post", } ) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index d12cc8d9a7b..fb4a0f4c1da 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -23,6 +23,7 @@ from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, + SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -543,3 +544,18 @@ async def test_stop_homeassistant(hass): assert not mock_check.called await hass.async_block_till_done() assert mock_restart.called + + +async def test_save_persistent_states(hass): + """Test we can call save_persistent_states.""" + await async_setup_component(hass, "homeassistant", {}) + with patch( + "homeassistant.helpers.restore_state.RestoreStateData.async_save_persistent_states", + return_value=None, + ) as mock_save: + await hass.services.async_call( + "homeassistant", + SERVICE_SAVE_PERSISTENT_STATES, + blocking=True, + ) + assert mock_save.called diff --git a/tests/components/homekit/common.py b/tests/components/homekit/common.py deleted file mode 100644 index 6b1d87e3f54..00000000000 --- a/tests/components/homekit/common.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Collection of fixtures and functions for the HomeKit tests.""" -from unittest.mock import Mock - -EMPTY_8_6_JPEG = b"empty_8_6" - - -def mock_turbo_jpeg( - first_width=None, second_width=None, first_height=None, second_height=None -): - """Mock a TurboJPEG instance.""" - mocked_turbo_jpeg = Mock() - mocked_turbo_jpeg.decode_header.side_effect = [ - (first_width, first_height, 0, 0), - (second_width, second_height, 0, 0), - ] - mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG - return mocked_turbo_jpeg diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 5441bcc195c..5e2acbcd9db 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -1,12 +1,15 @@ """HomeKit session fixtures.""" +from contextlib import suppress +import os from unittest.mock import patch from pyhap.accessory_driver import AccessoryDriver import pytest +from homeassistant.components.device_tracker.legacy import YAML_DEVICES from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED -from tests.common import async_capture_events +from tests.common import async_capture_events, mock_device_registry, mock_registry @pytest.fixture @@ -24,7 +27,46 @@ def hk_driver(loop): yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) +@pytest.fixture +def mock_hap(loop, mock_zeroconf): + """Return a custom AccessoryDriver instance for HomeKit accessory init.""" + with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( + "pyhap.accessory_driver.AccessoryEncoder" + ), patch("pyhap.accessory_driver.HAPServer.async_stop"), patch( + "pyhap.accessory_driver.HAPServer.async_start" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.publish" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.async_stop" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ): + yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) + + @pytest.fixture def events(hass): """Yield caught homekit_changed events.""" return async_capture_events(hass, EVENT_HOMEKIT_CHANGED) + + +@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) + + +@pytest.fixture +def demo_cleanup(hass): + """Clean up device tracker demo file.""" + yield + with suppress(FileNotFoundError): + os.remove(hass.config.path(YAML_DEVICES)) diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 0584ee4c0ff..3f08ca6fda2 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -14,9 +14,6 @@ from homeassistant.components.homekit.accessories import ( from homeassistant.components.homekit.const import ( ATTR_DISPLAY_NAME, ATTR_INTEGRATION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_NAME, @@ -36,7 +33,10 @@ from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, + ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_SERVICE, + ATTR_SW_VERSION, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -109,7 +109,7 @@ async def test_home_accessory(hass, hk_driver): { ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", - ATTR_SOFTWARE_VERSION: "0.4.3 that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_SW_VERSION: "0.4.3 that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_INTEGRATION: "luxe that exceeds the maximum maximum maximum maximum maximum maximum length", }, ) @@ -142,7 +142,7 @@ async def test_home_accessory(hass, hk_driver): { ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", - ATTR_SOFTWARE_VERSION: "will_not_match_regex", + ATTR_SW_VERSION: "will_not_match_regex", ATTR_INTEGRATION: "luxe that exceeds the maximum maximum maximum maximum maximum maximum length", }, ) @@ -205,7 +205,7 @@ async def test_accessory_with_missing_basic_service_info(hass, hk_driver): { ATTR_MODEL: None, ATTR_MANUFACTURER: None, - ATTR_SOFTWARE_VERSION: None, + ATTR_SW_VERSION: None, ATTR_INTEGRATION: None, }, ) @@ -651,7 +651,7 @@ def test_home_bridge(hk_driver): assert bridge.hass == "hass" assert bridge.display_name == BRIDGE_NAME assert bridge.category == 2 # Category.BRIDGE - assert len(bridge.services) == 1 + assert len(bridge.services) == 2 serv = bridge.services[0] # SERV_ACCESSORY_INFO assert serv.display_name == SERV_ACCESSORY_INFO assert serv.get_characteristic(CHAR_NAME).value == BRIDGE_NAME @@ -662,7 +662,7 @@ def test_home_bridge(hk_driver): bridge = HomeBridge("hass", hk_driver, "test_name") assert bridge.display_name == "test_name" - assert len(bridge.services) == 1 + assert len(bridge.services) == 2 serv = bridge.services[0] # SERV_ACCESSORY_INFO # setup_message @@ -696,9 +696,9 @@ def test_home_driver(): with patch("pyhap.accessory_driver.AccessoryDriver.pair") as mock_pair, patch( "homeassistant.components.homekit.accessories.dismiss_setup_message" ) as mock_dissmiss_msg: - driver.pair("client_uuid", "client_public") + driver.pair("client_uuid", "client_public", b"1") - mock_pair.assert_called_with("client_uuid", "client_public") + mock_pair.assert_called_with("client_uuid", "client_public", b"1") mock_dissmiss_msg.assert_called_with("hass", "entry_id") # unpair diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index f3707f9f71e..af803d50cf4 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.homekit.const import DOMAIN, SHORT_BRIDGE_NAME from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -314,6 +315,7 @@ async def test_options_flow_exclude_mode_advanced(auto_start, hass): assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { "auto_start": auto_start, + "devices": [], "mode": "bridge", "filter": { "exclude_domains": [], @@ -365,6 +367,138 @@ async def test_options_flow_exclude_mode_basic(hass): } +async def test_options_flow_devices( + mock_hap, hass, demo_cleanup, device_reg, entity_reg +): + """Test devices can be bridged.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + demo_config_entry = MockConfigEntry(domain="domain") + demo_config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "persistent_notification", {}) + assert await async_setup_component(hass, "demo", {"demo": {}}) + assert await async_setup_component(hass, "homekit", {"homekit": {}}) + + hass.states.async_set("climate.old", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) + + 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"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + + entry = entity_reg.async_get("light.ceiling_lights") + assert entry is not None + device_id = entry.device_id + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": ["climate.old"], + "include_exclude_mode": "exclude", + }, + ) + + with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"auto_start": True, "devices": [device_id]}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "devices": [device_id], + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.old"], + "include_domains": ["fan", "vacuum", "climate"], + "include_entities": [], + }, + } + + +async def test_options_flow_devices_preserved_when_advanced_off(mock_hap, hass): + """Test devices are preserved if they were added in advanced mode but it was turned off.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + options={ + "devices": ["1fabcabcabcabcabcabcabcabcabc"], + "filter": { + "include_domains": [ + "fan", + "humidifier", + "vacuum", + "media_player", + "climate", + "alarm_control_panel", + ], + "exclude_entities": ["climate.front_gate"], + }, + }, + ) + config_entry.add_to_hass(hass) + + demo_config_entry = MockConfigEntry(domain="domain") + demo_config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "persistent_notification", {}) + assert await async_setup_component(hass, "homekit", {"homekit": {}}) + + hass.states.async_set("climate.old", "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"]}, + ) + + 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": ["climate.old"], + "include_exclude_mode": "exclude", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "devices": ["1fabcabcabcabcabcabcabcabcabc"], + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.old"], + "include_domains": ["fan", "vacuum", "climate"], + "include_entities": [], + }, + } + + async def test_options_flow_include_mode_basic(hass): """Test config flow options in include mode.""" @@ -646,6 +780,7 @@ async def test_options_flow_blocked_when_from_yaml(hass): data={CONF_NAME: "mock_name", CONF_PORT: 12345}, options={ "auto_start": True, + "devices": [], "filter": { "include_domains": [ "fan", diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 138c8fd8209..4976985fa15 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -37,6 +37,7 @@ from homeassistant.components.homekit.const import ( SERVICE_HOMEKIT_START, SERVICE_HOMEKIT_UNPAIR, ) +from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( @@ -70,7 +71,7 @@ from homeassistant.util import json as json_util from .util import PATH_HOMEKIT, async_init_entry, async_init_integration -from tests.common import MockConfigEntry, mock_device_registry, mock_registry +from tests.common import MockConfigEntry IP_ADDRESS = "127.0.0.1" @@ -101,19 +102,7 @@ def always_patch_driver(hk_driver): """Load the hk_driver fixture.""" -@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) - - -def _mock_homekit(hass, entry, homekit_mode, entity_filter=None): +def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None): return HomeKit( hass=hass, name=BRIDGE_NAME, @@ -126,6 +115,7 @@ def _mock_homekit(hass, entry, homekit_mode, entity_filter=None): advertise_ip=None, entry_id=entry.entry_id, entry_title=entry.title, + devices=devices, ) @@ -178,6 +168,7 @@ async def test_setup_min(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) # Test auto start enabled @@ -214,6 +205,7 @@ async def test_setup_auto_start_disabled(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) # Test auto_start disabled @@ -560,7 +552,7 @@ async def test_homekit_start(hass, hk_driver, mock_zeroconf, device_reg): assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections assert len(device_reg.devices) == 1 - assert homekit.driver.state.config_version == 2 + assert homekit.driver.state.config_version == 1 async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroconf): @@ -602,6 +594,41 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc assert not hk_driver_start.called +async def test_homekit_start_with_a_device( + hass, hk_driver, mock_zeroconf, demo_cleanup, device_reg, entity_reg +): + """Test HomeKit start method with a device.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + assert await async_setup_component(hass, "demo", {"demo": {}}) + await hass.async_block_till_done() + + reg_entry = entity_reg.async_get("light.ceiling_lights") + assert reg_entry is not None + device_id = reg_entry.device_id + await async_init_entry(hass, entry) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, None, devices=[device_id]) + homekit.driver = hk_driver + + with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=Exception), patch( + f"{PATH_HOMEKIT}.show_setup_message" + ) as mock_setup_msg: + await homekit.async_start() + + await hass.async_block_till_done() + mock_setup_msg.assert_called_with( + hass, entry.entry_id, "Mock Title (Home Assistant Bridge)", ANY, ANY + ) + assert homekit.status == STATUS_RUNNING + + assert isinstance( + list(homekit.driver.accessory.accessories.values())[0], DeviceTriggerAccessory + ) + await homekit.async_stop() + + async def test_homekit_stop(hass): """Test HomeKit stop method.""" entry = await async_init_integration(hass) @@ -695,7 +722,7 @@ async def test_homekit_unpair(hass, device_reg, mock_zeroconf): homekit.status = STATUS_RUNNING state = homekit.driver.state - state.paired_clients = {"client1": "any"} + state.add_paired_client("client1", "any", b"1") formatted_mac = device_registry.format_mac(state.mac) hk_bridge_dev = device_reg.async_get_device( {}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)} @@ -734,7 +761,7 @@ async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf) homekit.status = STATUS_RUNNING state = homekit.driver.state - state.paired_clients = {"client1": "any"} + state.add_paired_client("client1", "any", b"1") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -780,7 +807,7 @@ async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf ) state = homekit.driver.state - state.paired_clients = {"client1": "any"} + state.add_paired_client("client1", "any", b"1") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -1141,6 +1168,7 @@ async def test_homekit_finds_linked_batteries( "manufacturer": "Tesla", "model": "Powerwall 2", "sw_version": "0.16.0", + "platform": "test", "linked_battery_charging_sensor": "binary_sensor.powerwall_battery_charging", "linked_battery_sensor": "sensor.powerwall_battery", }, @@ -1250,6 +1278,7 @@ async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) # Test auto start enabled @@ -1416,6 +1445,7 @@ async def test_homekit_finds_linked_motion_sensors( { "manufacturer": "Ubq", "model": "Camera Server", + "platform": "test", "sw_version": "0.16.0", "linked_motion_sensor": "binary_sensor.camera_motion_sensor", }, @@ -1480,6 +1510,7 @@ async def test_homekit_finds_linked_humidity_sensors( { "manufacturer": "Home Assistant", "model": "Smart Brainy Clever Humidifier", + "platform": "test", "sw_version": "0.16.1", "linked_humidity_sensor": "sensor.humidifier_humidity_sensor", }, @@ -1518,6 +1549,7 @@ async def test_reload(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) yaml_path = os.path.join( _get_fixtures_base_path(), @@ -1556,6 +1588,7 @@ async def test_reload(hass, mock_zeroconf): None, entry.entry_id, entry.title, + devices=[], ) diff --git a/tests/components/homekit/test_img_util.py b/tests/components/homekit/test_img_util.py deleted file mode 100644 index 45af8e6b1e6..00000000000 --- a/tests/components/homekit/test_img_util.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Test HomeKit img_util module.""" -from unittest.mock import patch - -from homeassistant.components.camera import Image -from homeassistant.components.homekit.img_util import ( - TurboJPEGSingleton, - scale_jpeg_camera_image, -) - -from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg - -EMPTY_16_12_JPEG = b"empty_16_12" - - -def test_turbojpeg_singleton(): - """Verify the instance always gives back the same.""" - assert TurboJPEGSingleton.instance() == TurboJPEGSingleton.instance() - - -def test_scale_jpeg_camera_image(): - """Test we can scale a jpeg image.""" - - camera_image = Image("image/jpeg", EMPTY_16_12_JPEG) - - turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) - with patch("turbojpeg.TurboJPEG", return_value=False): - TurboJPEGSingleton() - assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content - - turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) - with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): - TurboJPEGSingleton() - assert scale_jpeg_camera_image(camera_image, 16, 12) == EMPTY_16_12_JPEG - - turbo_jpeg = mock_turbo_jpeg( - first_width=16, first_height=12, second_width=8, second_height=6 - ) - with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): - TurboJPEGSingleton() - jpeg_bytes = scale_jpeg_camera_image(camera_image, 8, 6) - - assert jpeg_bytes == EMPTY_8_6_JPEG - - -def test_turbojpeg_load_failure(): - """Handle libjpegturbo not being installed.""" - - with patch("turbojpeg.TurboJPEG", side_effect=Exception): - TurboJPEGSingleton() - assert TurboJPEGSingleton.instance() is False - - with patch("turbojpeg.TurboJPEG"): - TurboJPEGSingleton() - assert TurboJPEGSingleton.instance() diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index b9df572a699..991965b30b5 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -7,6 +7,7 @@ from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components import camera, ffmpeg +from homeassistant.components.camera.img_util import TurboJPEGSingleton from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( AUDIO_CODEC_COPY, @@ -26,14 +27,13 @@ from homeassistant.components.homekit.const import ( VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, ) -from homeassistant.components.homekit.img_util import TurboJPEGSingleton from homeassistant.components.homekit.type_cameras import Camera from homeassistant.components.homekit.type_switches import Switch from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from .common import mock_turbo_jpeg +from tests.components.camera.common import mock_turbo_jpeg MOCK_START_STREAM_TLV = "ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA" MOCK_END_POINTS_TLV = "ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA==" diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index f75e6bf19ac..de0fd532ec9 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,21 +1,23 @@ """Test different accessory types: Lights.""" +from datetime import timedelta + from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest from homeassistant.components.homekit.const import ATTR_VALUE -from homeassistant.components.homekit.type_lights import Light +from homeassistant.components.homekit.type_lights import ( + CHANGE_COALESCE_TIME_WINDOW, + Light, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, - COLOR_MODE_COLOR_TEMP, - COLOR_MODE_HS, - COLOR_MODE_RGB, - COLOR_MODE_XY, DOMAIN, ) from homeassistant.const import ( @@ -29,8 +31,16 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util -from tests.common import async_mock_service +from tests.common import async_fire_time_changed, async_mock_service + + +async def _wait_for_light_coalesce(hass): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=CHANGE_COALESCE_TIME_WINDOW) + ) + await hass.async_block_till_done() async def test_light_basic(hass, hk_driver, events): @@ -44,45 +54,41 @@ async def test_light_basic(hass, hk_driver, events): assert acc.aid == 1 assert acc.category == 5 # Lightbulb - assert acc.char_on_primary.value + assert acc.char_on.value await acc.run() await hass.async_block_till_done() - assert acc.char_on_primary.value == 1 + assert acc.char_on.value == 1 hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() - assert acc.char_on_primary.value == 0 + assert acc.char_on.value == 0 hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() - assert acc.char_on_primary.value == 0 + assert acc.char_on.value == 0 hass.states.async_remove(entity_id) await hass.async_block_till_done() - assert acc.char_on_primary.value == 0 + assert acc.char_on.value == 0 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") - char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 1, - } + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1} ] }, "mock_addr", ) - await hass.async_add_executor_job(acc.char_on_primary.client_update_value, 1) - await hass.async_block_till_done() + acc.char_on.client_update_value(1) + await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 1 @@ -94,16 +100,12 @@ async def test_light_basic(hass, hk_driver, events): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 0, - } + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 0} ] }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 2 @@ -128,17 +130,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness_primary.value != 0 - char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] - char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness.value != 0 + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 40 + assert acc.char_brightness.value == 40 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") @@ -147,21 +149,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 20, }, ] }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 @@ -173,21 +171,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 40, }, ] }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on[1] assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40 @@ -199,21 +193,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 0, }, ] }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 3 @@ -223,24 +213,24 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): # in update_state hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 1 + assert acc.char_brightness.value == 1 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 255}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 1 + assert acc.char_brightness.value == 1 # Ensure floats are handled hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 55.66}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 22 + assert acc.char_brightness.value == 22 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 108.4}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 43 + assert acc.char_brightness.value == 43 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0.0}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 1 + assert acc.char_brightness.value == 1 async def test_light_color_temperature(hass, hk_driver, events): @@ -256,33 +246,30 @@ async def test_light_color_temperature(hass, hk_driver, events): acc = Light(hass, hk_driver, "Light", entity_id, 1, None) hk_driver.add_accessory(acc) - assert acc.char_color_temperature.value == 190 + assert acc.char_color_temp.value == 190 await acc.run() await hass.async_block_till_done() - assert acc.char_color_temperature.value == 190 + assert acc.char_color_temp.value == 190 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_color_temperature_iid, + HAP_REPR_IID: char_color_temp_iid, HAP_REPR_VALUE: 250, } ] }, "mock_addr", ) - await hass.async_add_executor_job( - acc.char_color_temperature.client_update_value, 250 - ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 @@ -292,11 +279,7 @@ async def test_light_color_temperature(hass, hk_driver, events): @pytest.mark.parametrize( "supported_color_modes", - [ - [COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS], - [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGB], - [COLOR_MODE_COLOR_TEMP, COLOR_MODE_XY], - ], + [["color_temp", "hs"], ["color_temp", "rgb"], ["color_temp", "xy"]], ) async def test_light_color_temperature_and_rgb_color( hass, hk_driver, events, supported_color_modes @@ -310,93 +293,190 @@ async def test_light_color_temperature_and_rgb_color( { ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_COLOR_TEMP: 190, - ATTR_BRIGHTNESS: 255, - ATTR_COLOR_MODE: COLOR_MODE_RGB, ATTR_HS_COLOR: (260, 90), }, ) await hass.async_block_till_done() acc = Light(hass, hk_driver, "Light", entity_id, 1, None) - assert acc.char_hue.value == 260 - assert acc.char_saturation.value == 90 - assert acc.char_on_primary.value == 1 - assert acc.char_on_secondary.value == 0 - assert acc.char_brightness_primary.value == 100 - assert acc.char_brightness_secondary.value == 100 - - assert hasattr(acc, "char_color_temperature") - - hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_COLOR_TEMP: 224, - ATTR_COLOR_MODE: COLOR_MODE_COLOR_TEMP, - ATTR_BRIGHTNESS: 127, - }, - ) - await hass.async_block_till_done() - await acc.run() - await hass.async_block_till_done() - assert acc.char_color_temperature.value == 224 - assert acc.char_on_primary.value == 0 - assert acc.char_on_secondary.value == 1 - assert acc.char_brightness_primary.value == 50 - assert acc.char_brightness_secondary.value == 50 - - hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_COLOR_TEMP: 352, - ATTR_COLOR_MODE: COLOR_MODE_COLOR_TEMP, - }, - ) - await hass.async_block_till_done() - await acc.run() - await hass.async_block_till_done() - assert acc.char_color_temperature.value == 352 - assert acc.char_on_primary.value == 0 - assert acc.char_on_secondary.value == 1 hk_driver.add_accessory(acc) + assert acc.char_color_temp.value == 190 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 16 + + assert hasattr(acc, "char_color_temp") + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 224}) + await hass.async_block_till_done() + await acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 224 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 27 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 352}) + await hass.async_block_till_done() + await acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 352 + assert acc.char_hue.value == 28 + assert acc.char_saturation.value == 61 + + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] - char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 250, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 50, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 50, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250" + ) + + # Only set Hue hk_driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_hue_iid, - HAP_REPR_VALUE: 145, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_saturation_iid, - HAP_REPR_VALUE: 75, - }, + HAP_REPR_VALUE: 30, + } ] }, "mock_addr", ) - assert acc.char_hue.value == 145 - assert acc.char_saturation.value == 75 + await _wait_for_light_coalesce(hass) + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_HS_COLOR] == (30, 50) + assert events[-1].data[ATTR_VALUE] == "set color at (30, 50)" + + # Only set Saturation hk_driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_color_temperature_iid, - HAP_REPR_VALUE: 200, - }, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 20, + } ] }, "mock_addr", ) - assert acc.char_color_temperature.value == 200 + await _wait_for_light_coalesce(hass) + assert call_turn_on[2] + assert call_turn_on[2].data[ATTR_HS_COLOR] == (30, 20) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 20)" + + # Generate a conflict by setting hue and then color temp + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 80, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 320, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[3] + assert call_turn_on[3].data[ATTR_COLOR_TEMP] == 320 + assert events[-1].data[ATTR_VALUE] == "color temperature at 320" + + # Generate a conflict by setting color temp then saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 404, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 35, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[4] + assert call_turn_on[4].data[ATTR_HS_COLOR] == (80, 35) + assert events[-1].data[ATTR_VALUE] == "set color at (80, 35)" + + # Set from HASS + hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (100, 100)}) + await hass.async_block_till_done() + await acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 404 + assert acc.char_hue.value == 100 + assert acc.char_saturation.value == 100 @pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) @@ -444,7 +524,7 @@ async def test_light_rgb_color(hass, hk_driver, events, supported_color_modes): }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_HS_COLOR] == (145, 75) @@ -476,13 +556,13 @@ async def test_light_restore(hass, hk_driver, events): hk_driver.add_accessory(acc) assert acc.category == 5 # Lightbulb - assert acc.chars_primary == [] - assert acc.char_on_primary.value == 0 + assert acc.chars == [] + assert acc.char_on.value == 0 acc = Light(hass, hk_driver, "Light", "light.all_info_set", 2, None) assert acc.category == 5 # Lightbulb - assert acc.chars_primary == ["Brightness"] - assert acc.char_on_primary.value == 0 + assert acc.chars == ["Brightness"] + assert acc.char_on.value == 0 async def test_light_set_brightness_and_color(hass, hk_driver, events): @@ -503,19 +583,19 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness_primary.value != 0 - char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] - char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness.value != 0 + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 40 + assert acc.char_brightness.value == 40 hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (4.5, 9.2)}) await hass.async_block_till_done() @@ -528,14 +608,10 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 20, }, { @@ -552,7 +628,7 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 @@ -565,6 +641,26 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): ) +async def test_light_min_max_mireds(hass, hk_driver, events): + """Test mireds are forced to ints.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], + ATTR_BRIGHTNESS: 255, + ATTR_MAX_MIREDS: 500.5, + ATTR_MIN_MIREDS: 100.5, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + acc.char_color_temp.properties["maxValue"] == 500 + acc.char_color_temp.properties["minValue"] == 100 + + async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): """Test light with all chars in one go.""" entity_id = "light.demo" @@ -583,22 +679,22 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness_primary.value != 0 - char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] - char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] - char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness.value != 0 + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness_primary.value == 40 + assert acc.char_brightness.value == 40 hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: (224.14)}) await hass.async_block_till_done() - assert acc.char_color_temperature.value == 224 + assert acc.char_color_temp.value == 224 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") @@ -606,26 +702,22 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_primary_iid, - HAP_REPR_VALUE: 1, - }, - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_primary_iid, + HAP_REPR_IID: char_brightness_iid, HAP_REPR_VALUE: 20, }, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_color_temperature_iid, + HAP_REPR_IID: char_color_temp_iid, HAP_REPR_VALUE: 250, }, ] }, "mock_addr", ) - await hass.async_block_till_done() + await _wait_for_light_coalesce(hass) assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py new file mode 100644 index 00000000000..4a265858cb3 --- /dev/null +++ b/tests/components/homekit/test_type_triggers.py @@ -0,0 +1,57 @@ +"""Test different accessory types: Triggers (Programmable Switches).""" + +from unittest.mock import MagicMock + +from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_get_device_automations + + +async def test_programmable_switch_button_fires_on_trigger( + hass, hk_driver, events, demo_cleanup, device_reg, entity_reg +): + """Test that DeviceTriggerAccessory fires the programmable switch event on trigger.""" + hk_driver.publish = MagicMock() + + demo_config_entry = MockConfigEntry(domain="domain") + demo_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "demo", {"demo": {}}) + await hass.async_block_till_done() + hass.states.async_set("light.ceiling_lights", STATE_OFF) + await hass.async_block_till_done() + + entry = entity_reg.async_get("light.ceiling_lights") + assert entry is not None + device_id = entry.device_id + + device_triggers = await async_get_device_automations(hass, "trigger", device_id) + acc = DeviceTriggerAccessory( + hass, + hk_driver, + "DeviceTriggerAccessory", + None, + 1, + None, + device_id=device_id, + device_triggers=device_triggers, + ) + await acc.run() + await hass.async_block_till_done() + + assert acc.entity_id is None + assert acc.device_id is device_id + assert acc.available is True + + hk_driver.publish.reset_mock() + hass.states.async_set("light.ceiling_lights", STATE_ON) + await hass.async_block_till_done() + hk_driver.publish.assert_called_once() + + hk_driver.publish.reset_mock() + hass.states.async_set("light.ceiling_lights", STATE_OFF) + await hass.async_block_till_done() + hk_driver.publish.assert_called_once() + + await acc.stop() diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py new file mode 100644 index 00000000000..86fb9f65f11 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py @@ -0,0 +1,84 @@ +"""Make sure that an Arlo Baby can be setup.""" + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_arlo_baby_setup(hass): + """Test that an Arlo Baby can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "arlo_baby.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + sensors = [ + ( + "camera.arlobabya0", + "homekit-00A0000000000-aid:1", + "ArloBabyA0", + ), + ( + "binary_sensor.arlobabya0", + "homekit-00A0000000000-500", + "ArloBabyA0", + ), + ( + "sensor.arlobabya0_battery", + "homekit-00A0000000000-700", + "ArloBabyA0 Battery", + ), + ( + "sensor.arlobabya0_humidity", + "homekit-00A0000000000-900", + "ArloBabyA0 Humidity", + ), + ( + "sensor.arlobabya0_temperature", + "homekit-00A0000000000-1000", + "ArloBabyA0 Temperature", + ), + ( + "sensor.arlobabya0_air_quality", + "homekit-00A0000000000-aid:1-sid:800-cid:802", + "ArloBabyA0 - Air Quality", + ), + ( + "light.arlobabya0", + "homekit-00A0000000000-1100", + "ArloBabyA0", + ), + ] + + device_ids = set() + + for (entity_id, unique_id, friendly_name) in sensors: + entry = entity_registry.async_get(entity_id) + assert entry.unique_id == unique_id + + helper = Helper( + hass, + entity_id, + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == friendly_name + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "Netgear, Inc" + assert device.name == "ArloBabyA0" + assert device.model == "ABC1000" + assert device.sw_version == "1.10.931" + assert device.via_device_id is None + + device_ids.add(entry.device_id) + + # All entities should be part of same device + assert len(device_ids) == 1 diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py new file mode 100644 index 00000000000..e419b140e94 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_eve_degree.py @@ -0,0 +1,74 @@ +"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_eve_degree_setup(hass): + """Test that the accessory can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "eve_degree.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + sensors = [ + ( + "sensor.eve_degree_aa11_temperature", + "homekit-AA00A0A00000-22", + "Eve Degree AA11 Temperature", + ), + ( + "sensor.eve_degree_aa11_humidity", + "homekit-AA00A0A00000-27", + "Eve Degree AA11 Humidity", + ), + ( + "sensor.eve_degree_aa11_air_pressure", + "homekit-AA00A0A00000-aid:1-sid:30-cid:32", + "Eve Degree AA11 - Air Pressure", + ), + ( + "sensor.eve_degree_aa11_battery", + "homekit-AA00A0A00000-17", + "Eve Degree AA11 Battery", + ), + ( + "number.eve_degree_aa11", + "homekit-AA00A0A00000-aid:1-sid:30-cid:33", + "Eve Degree AA11", + ), + ] + + device_ids = set() + + for (entity_id, unique_id, friendly_name) in sensors: + entry = entity_registry.async_get(entity_id) + assert entry.unique_id == unique_id + + helper = Helper( + hass, + entity_id, + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == friendly_name + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "Elgato" + assert device.name == "Eve Degree AA11" + assert device.model == "Eve Degree 00AAA0000" + assert device.sw_version == "1.2.8" + assert device.via_device_id is None + + device_ids.add(entry.device_id) + + # All entities should be part of same device + assert len(device_ids) == 1 diff --git a/tests/components/homekit_controller/test_air_quality.py b/tests/components/homekit_controller/test_air_quality.py index 52c79f2b28a..2477c6bacfd 100644 --- a/tests/components/homekit_controller/test_air_quality.py +++ b/tests/components/homekit_controller/test_air_quality.py @@ -2,6 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +from homeassistant.helpers import entity_registry as er + from tests.components.homekit_controller.common import setup_test_component @@ -35,6 +38,12 @@ async def test_air_quality_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit temperature sensor accessory.""" helper = await setup_test_component(hass, create_air_quality_sensor_service) + entity_registry = er.async_get(hass) + entity_registry.async_update_entity( + entity_id="air_quality.testdevice", disabled_by=None + ) + await hass.async_block_till_done() + state = await helper.poll_and_get_state() assert state.state == "4444" @@ -45,3 +54,47 @@ async def test_air_quality_sensor_read_state(hass, utcnow): assert state.attributes["particulate_matter_2_5"] == 4444 assert state.attributes["particulate_matter_10"] == 5555 assert state.attributes["volatile_organic_compounds"] == 6666 + + +async def test_air_quality_sensor_read_state_even_if_air_quality_off(hass, utcnow): + """The air quality entity is disabled by default, the replacement sensors should always be available.""" + await setup_test_component(hass, create_air_quality_sensor_service) + + entity_registry = er.async_get(hass) + + sensors = [ + {"entity_id": "sensor.testdevice_air_quality"}, + { + "entity_id": "sensor.testdevice_pm10_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_pm2_5_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_pm10_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_ozone_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_sulphur_dioxide_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_nitrogen_dioxide_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + { + "entity_id": "sensor.testdevice_volatile_organic_compound_density", + "units": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + ] + + for sensor in sensors: + entry = entity_registry.async_get(sensor["entity_id"]) + assert entry is not None + assert entry.unit_of_measurement == sensor.get("units") diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 604c83e54f7..f96569551d8 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -1,6 +1,7 @@ """Basic checks for HomeKit sensor.""" from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from aiohomekit.protocol.statuscodes import HapStatusCode from homeassistant.const import ( DEVICE_CLASS_BATTERY, @@ -236,3 +237,30 @@ async def test_switch_with_sensor(hass, utcnow): realtime_energy.value = 50 state = await energy_helper.poll_and_get_state() assert state.state == "50" + + +async def test_sensor_unavailable(hass, utcnow): + """Test a sensor becoming unavailable.""" + helper = await setup_test_component(hass, create_switch_with_sensor) + + # Find the energy sensor and mark it as offline + outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + realtime_energy = outlet[CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY] + realtime_energy.status = HapStatusCode.UNABLE_TO_COMMUNICATE + + # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. + energy_helper = Helper( + hass, + "sensor.testdevice_real_time_energy", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + # Outlet has non-responsive characteristics so should be unavailable + state = await helper.poll_and_get_state() + assert state.state == "unavailable" + + # Energy sensor has non-responsive characteristics so should be unavailable + state = await energy_helper.poll_and_get_state() + assert state.state == "unavailable" diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index 05e3631e08d..ff5ec57e27c 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -31,7 +31,7 @@ def config_entry(config_data): def device(): """Mock a somecomfort.Device.""" mock_device = create_autospec(somecomfort.Device, instance=True) - mock_device.deviceid.return_value = "device1" + mock_device.deviceid = 1234567 mock_device._data = { "canControlHumidification": False, "hasFan": False, @@ -43,6 +43,22 @@ def device(): return mock_device +@pytest.fixture +def another_device(): + """Mock a somecomfort.Device.""" + mock_device = create_autospec(somecomfort.Device, instance=True) + mock_device.deviceid = 7654321 + mock_device._data = { + "canControlHumidification": False, + "hasFan": False, + } + mock_device.system_mode = "off" + mock_device.name = "device2" + mock_device.current_temperature = 20 + mock_device.mac_address = "macaddress1" + return mock_device + + @pytest.fixture def location(device): """Mock a somecomfort.Location.""" diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index d0bdb5ccf2d..7cc6b64cd63 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -1,8 +1,27 @@ """Test honeywell setup process.""" +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant -async def test_setup_entry(hass, config_entry): +from tests.common import MockConfigEntry + + +async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry): """Initialize the config entry.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.async_entity_ids_count() == 1 + + +async def test_setup_multiple_thermostats( + hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device +) -> None: + """Test that the config form is shown.""" + location.devices_by_id[another_device.deviceid] = another_device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.async_entity_ids_count() == 2 diff --git a/tests/components/hue/test_device_trigger.py b/tests/components/hue/test_device_trigger.py index 28bb989d475..d0c20018c30 100644 --- a/tests/components/hue/test_device_trigger.py +++ b/tests/components/hue/test_device_trigger.py @@ -55,7 +55,7 @@ async def test_get_triggers(hass, mock_bridge, device_reg): "type": t_type, "subtype": t_subtype, } - for t_type, t_subtype in device_trigger.HUE_TAP_REMOTE.keys() + for t_type, t_subtype in device_trigger.HUE_TAP_REMOTE ] assert_lists_same(triggers, expected_triggers) @@ -82,7 +82,7 @@ async def test_get_triggers(hass, mock_bridge, device_reg): "type": t_type, "subtype": t_subtype, } - for t_type, t_subtype in device_trigger.HUE_DIMMER_REMOTE.keys() + for t_type, t_subtype in device_trigger.HUE_DIMMER_REMOTE ), ] assert_lists_same(triggers, expected_triggers) @@ -140,6 +140,7 @@ async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): # Fake that the remote is being pressed. new_sensor_response = dict(REMOTES_RESPONSE) + new_sensor_response["7"] = dict(new_sensor_response["7"]) new_sensor_response["7"]["state"] = { "buttonevent": 18, "lastupdated": "2019-12-28T22:58:02", @@ -156,7 +157,7 @@ async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): assert calls[0].data["some"] == "B4 - 18" # Fake another button press. - new_sensor_response = dict(REMOTES_RESPONSE) + new_sensor_response["7"] = dict(new_sensor_response["7"]) new_sensor_response["7"]["state"] = { "buttonevent": 34, "lastupdated": "2019-12-28T22:58:05", diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py index f4f663c23ae..6025b725c60 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light.py @@ -260,7 +260,7 @@ async def test_lights_color_mode(hass, mock_bridge): assert lamp_1.state == "on" assert lamp_1.attributes["brightness"] == 145 assert lamp_1.attributes["color_temp"] == 467 - assert "hs_color" not in lamp_1.attributes + assert "hs_color" in lamp_1.attributes async def test_groups(hass, mock_bridge): diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index b8e9c83e47d..5b3b6619efe 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -450,6 +450,7 @@ async def test_hue_events(hass, mock_bridge): mock_bridge.api.sensors["8"].last_event = {"type": "button"} new_sensor_response = dict(SENSOR_RESPONSE) + new_sensor_response["7"] = dict(new_sensor_response["7"]) new_sensor_response["7"]["state"] = { "buttonevent": 18, "lastupdated": "2019-12-28T22:58:03", @@ -473,6 +474,7 @@ async def test_hue_events(hass, mock_bridge): } new_sensor_response = dict(new_sensor_response) + new_sensor_response["8"] = dict(new_sensor_response["8"]) new_sensor_response["8"]["state"] = { "buttonevent": 3002, "lastupdated": "2019-12-28T22:58:03", @@ -497,6 +499,7 @@ async def test_hue_events(hass, mock_bridge): # Fire old event, it should be ignored new_sensor_response = dict(new_sensor_response) + new_sensor_response["8"] = dict(new_sensor_response["8"]) new_sensor_response["8"]["state"] = { "buttonevent": 18, "lastupdated": "2019-12-28T22:58:02", diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 36d3d4b3b30..e8aaf906936 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_MEASUREMENT +from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -39,8 +39,7 @@ async def test_state(hass) -> None: state = hass.states.get("sensor.integration") assert state is not None - assert state.attributes.get("last_reset") == now.isoformat() - assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING assert "device_class" not in state.attributes future_now = dt_util.utcnow() + timedelta(seconds=3600) @@ -58,8 +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_MEASUREMENT - assert state.attributes.get("last_reset") == now.isoformat() + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING async def test_restore_state(hass: HomeAssistant) -> None: @@ -71,7 +69,6 @@ async def test_restore_state(hass: HomeAssistant) -> None: "sensor.integration", "100.0", { - "last_reset": "2019-10-06T21:00:00", "device_class": DEVICE_CLASS_ENERGY, "unit_of_measurement": ENERGY_KILO_WATT_HOUR, }, @@ -97,7 +94,6 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.state == "100.00" assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY - assert state.attributes.get("last_reset") == "2019-10-06T21:00:00" async def test_restore_state_failed(hass: HomeAssistant) -> None: @@ -108,9 +104,7 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: State( "sensor.integration", "INVALID", - { - "last_reset": "2019-10-06T21:00:00.000000", - }, + {}, ), ), ) @@ -131,8 +125,7 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: assert state assert state.state == "0" assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR - assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT - assert state.attributes.get("last_reset") != "2019-10-06T21:00:00" + assert state.attributes.get("state_class") == STATE_CLASS_TOTAL_INCREASING assert "device_class" not in state.attributes diff --git a/tests/components/iotawatt/__init__.py b/tests/components/iotawatt/__init__.py new file mode 100644 index 00000000000..3d1afe1b88b --- /dev/null +++ b/tests/components/iotawatt/__init__.py @@ -0,0 +1,21 @@ +"""Tests for the IoTaWatt integration.""" +from iotawattpy.sensor import Sensor + +INPUT_SENSOR = Sensor( + channel="1", + name="My Sensor", + io_type="Input", + unit="WattHours", + value="23", + begin="", + mac_addr="mock-mac", +) +OUTPUT_SENSOR = Sensor( + channel="N/A", + name="My WattHour Sensor", + io_type="Output", + unit="WattHours", + value="243", + begin="", + mac_addr="mock-mac", +) diff --git a/tests/components/iotawatt/conftest.py b/tests/components/iotawatt/conftest.py new file mode 100644 index 00000000000..f96201ba50e --- /dev/null +++ b/tests/components/iotawatt/conftest.py @@ -0,0 +1,27 @@ +"""Test fixtures for IoTaWatt.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.iotawatt import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def entry(hass): + """Mock config entry added to HA.""" + entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_iotawatt(entry): + """Mock iotawatt.""" + with patch("homeassistant.components.iotawatt.coordinator.Iotawatt") as mock: + instance = mock.return_value + instance.connect = AsyncMock(return_value=True) + instance.update = AsyncMock() + instance.getSensors.return_value = {"sensors": {}} + yield instance diff --git a/tests/components/iotawatt/test_config_flow.py b/tests/components/iotawatt/test_config_flow.py new file mode 100644 index 00000000000..e028f365431 --- /dev/null +++ b/tests/components/iotawatt/test_config_flow.py @@ -0,0 +1,143 @@ +"""Test the IoTawatt config flow.""" +from unittest.mock import patch + +import httpx + +from homeassistant import config_entries, setup +from homeassistant.components.iotawatt.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +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["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.iotawatt.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "host": "1.1.1.1", + } + + +async def test_form_auth(hass: HomeAssistant) -> None: + """Test we handle auth.""" + + 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["step_id"] == "user" + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "auth" + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=False, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "mock-user", + "password": "mock-pass", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["step_id"] == "auth" + assert result3["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.iotawatt.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + return_value=True, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "mock-user", + "password": "mock-pass", + }, + ) + await hass.async_block_till_done() + + assert result4["type"] == RESULT_TYPE_CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + assert result4["data"] == { + "host": "1.1.1.1", + "username": "mock-user", + "password": "mock-pass", + } + + +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.iotawatt.config_flow.Iotawatt.connect", + side_effect=httpx.HTTPError("any"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_setup_exception(hass: HomeAssistant) -> None: + """Test we handle broad exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.iotawatt.config_flow.Iotawatt.connect", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/iotawatt/test_init.py b/tests/components/iotawatt/test_init.py new file mode 100644 index 00000000000..b43a3d9aa88 --- /dev/null +++ b/tests/components/iotawatt/test_init.py @@ -0,0 +1,31 @@ +"""Test init.""" +import httpx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.setup import async_setup_component + +from . import INPUT_SENSOR + + +async def test_setup_unload(hass, mock_iotawatt, entry): + """Test we can setup and unload an entry.""" + mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_setup_connection_failed(hass, mock_iotawatt, entry): + """Test connection error during startup.""" + mock_iotawatt.connect.side_effect = httpx.ConnectError("") + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_failed(hass, mock_iotawatt, entry): + """Test auth error during startup.""" + mock_iotawatt.connect.return_value = False + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py new file mode 100644 index 00000000000..a5fc2250b84 --- /dev/null +++ b/tests/components/iotawatt/test_sensor.py @@ -0,0 +1,72 @@ +"""Test setting up sensors.""" +from datetime import timedelta + +from homeassistant.components.sensor import ATTR_STATE_CLASS, DEVICE_CLASS_ENERGY +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_WATT_HOUR, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from . import INPUT_SENSOR, OUTPUT_SENSOR + +from tests.common import async_fire_time_changed + + +async def test_sensor_type_input(hass, mock_iotawatt): + """Test input sensors work.""" + assert await async_setup_component(hass, "iotawatt", {}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 0 + + # Discover this sensor during a regular update. + mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + 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_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["channel"] == "1" + assert state.attributes["type"] == "Input" + + mock_iotawatt.getSensors.return_value["sensors"].pop("my_sensor_key") + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_sensor") is None + + +async def test_sensor_type_output(hass, mock_iotawatt): + """Tests the sensor type of Output.""" + mock_iotawatt.getSensors.return_value["sensors"][ + "my_watthour_sensor_key" + ] = OUTPUT_SENSOR + 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_sensor") + assert state is not None + assert state.state == "243" + 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 + assert state.attributes["type"] == "Output" + + mock_iotawatt.getSensors.return_value["sensors"].pop("my_watthour_sensor_key") + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_watthour_sensor") is None diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py new file mode 100644 index 00000000000..48b871b85e4 --- /dev/null +++ b/tests/components/knx/test_binary_sensor.py @@ -0,0 +1,205 @@ +"""Test KNX binary sensor.""" +from datetime import timedelta + +from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.schema import BinarySensorSchema +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from .conftest import KNXTestKit + +from tests.common import async_capture_events, async_fire_time_changed + + +async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX binary sensor and inverted binary_sensor.""" + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test_normal", + CONF_STATE_ADDRESS: "1/1/1", + }, + { + CONF_NAME: "test_invert", + CONF_STATE_ADDRESS: "2/2/2", + BinarySensorSchema.CONF_INVERT: True, + }, + ] + } + ) + assert len(hass.states.async_all()) == 2 + + # StateUpdater initialize state + await knx.assert_read("1/1/1") + await knx.assert_read("2/2/2") + await knx.receive_response("1/1/1", True) + await knx.receive_response("2/2/2", False) + state_normal = hass.states.get("binary_sensor.test_normal") + state_invert = hass.states.get("binary_sensor.test_invert") + assert state_normal.state is STATE_ON + assert state_invert.state is STATE_ON + + # receive OFF telegram + await knx.receive_write("1/1/1", False) + await knx.receive_write("2/2/2", True) + state_normal = hass.states.get("binary_sensor.test_normal") + state_invert = hass.states.get("binary_sensor.test_invert") + assert state_normal.state is STATE_OFF + assert state_invert.state is STATE_OFF + + # receive ON telegram + await knx.receive_write("1/1/1", True) + await knx.receive_write("2/2/2", False) + state_normal = hass.states.get("binary_sensor.test_normal") + state_invert = hass.states.get("binary_sensor.test_invert") + assert state_normal.state is STATE_ON + assert state_invert.state is STATE_ON + + # binary_sensor does not respond to read + await knx.receive_read("1/1/1") + await knx.receive_read("2/2/2") + await knx.assert_telegram_count(0) + + +async def test_binary_sensor_ignore_internal_state( + hass: HomeAssistant, knx: KNXTestKit +): + """Test KNX binary_sensor with ignore_internal_state.""" + events = async_capture_events(hass, "state_changed") + + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test_normal", + CONF_STATE_ADDRESS: "1/1/1", + CONF_SYNC_STATE: False, + }, + { + CONF_NAME: "test_ignore", + CONF_STATE_ADDRESS: "2/2/2", + BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE: True, + CONF_SYNC_STATE: False, + }, + ] + } + ) + assert len(hass.states.async_all()) == 2 + # binary_sensor defaults to STATE_OFF - state change form None + assert len(events) == 2 + + # receive initial ON telegram + await knx.receive_write("1/1/1", True) + await knx.receive_write("2/2/2", True) + await hass.async_block_till_done() + assert len(events) == 4 + + # receive second ON telegram - ignore_internal_state shall force state_changed event + await knx.receive_write("1/1/1", True) + await knx.receive_write("2/2/2", True) + await hass.async_block_till_done() + assert len(events) == 5 + + # receive first OFF telegram + await knx.receive_write("1/1/1", False) + await knx.receive_write("2/2/2", False) + await hass.async_block_till_done() + assert len(events) == 7 + + # receive second OFF telegram - ignore_internal_state shall force state_changed event + await knx.receive_write("1/1/1", False) + await knx.receive_write("2/2/2", False) + await hass.async_block_till_done() + assert len(events) == 8 + + +async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX binary_sensor with context timeout.""" + async_fire_time_changed(hass, dt.utcnow()) + events = async_capture_events(hass, "state_changed") + + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "2/2/2", + BinarySensorSchema.CONF_CONTEXT_TIMEOUT: 1, + CONF_SYNC_STATE: False, + }, + ] + } + ) + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + # receive initial ON telegram + await knx.receive_write("2/2/2", True) + await hass.async_block_till_done() + # no change yet - still in 1 sec context (additional async_block_till_done needed for time change) + assert len(events) == 0 + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_OFF + assert state.attributes.get("counter") == 0 + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + # state changed twice after context timeout - once to ON with counter 1 and once to counter 0 + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + assert state.attributes.get("counter") == 0 + # additional async_block_till_done needed event capture + await hass.async_block_till_done() + assert len(events) == 2 + assert events.pop(0).data.get("new_state").attributes.get("counter") == 1 + assert events.pop(0).data.get("new_state").attributes.get("counter") == 0 + + # receive 2 telegrams in context + await knx.receive_write("2/2/2", True) + await knx.receive_write("2/2/2", True) + assert len(events) == 0 + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + assert state.attributes.get("counter") == 0 + await hass.async_block_till_done() + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + assert state.attributes.get("counter") == 0 + await hass.async_block_till_done() + assert len(events) == 2 + assert events.pop(0).data.get("new_state").attributes.get("counter") == 2 + assert events.pop(0).data.get("new_state").attributes.get("counter") == 0 + + +async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX binary_sensor with reset_after function.""" + async_fire_time_changed(hass, dt.utcnow()) + + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "2/2/2", + BinarySensorSchema.CONF_RESET_AFTER: 1, + CONF_SYNC_STATE: False, + }, + ] + } + ) + assert len(hass.states.async_all()) == 1 + + # receive ON telegram + await knx.receive_write("2/2/2", True) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + # state reset after after timeout + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_OFF diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py new file mode 100644 index 00000000000..cc2365888f0 --- /dev/null +++ b/tests/components/knx/test_fan.py @@ -0,0 +1,147 @@ +"""Test KNX fan.""" +from homeassistant.components.knx.const import KNX_ADDRESS +from homeassistant.components.knx.schema import FanSchema +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + + +async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX fan with percentage speed.""" + await knx.setup_integration( + { + FanSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + } + } + ) + assert len(hass.states.async_all()) == 1 + + # turn on fan with default speed (50%) + await hass.services.async_call( + "fan", "turn_on", {"entity_id": "fan.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (128,)) + + # turn off fan + await hass.services.async_call( + "fan", "turn_off", {"entity_id": "fan.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (0,)) + + # receive 100% telegram + await knx.receive_write("1/2/3", (0xFF,)) + state = hass.states.get("fan.test") + assert state.state is STATE_ON + + # receive 80% telegram + await knx.receive_write("1/2/3", (0xCC,)) + state = hass.states.get("fan.test") + assert state.state is STATE_ON + assert state.attributes.get("percentage") == 80 + + # receive 0% telegram + await knx.receive_write("1/2/3", (0,)) + state = hass.states.get("fan.test") + assert state.state is STATE_OFF + + # fan does not respond to read + await knx.receive_read("1/2/3") + await knx.assert_telegram_count(0) + + +async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX fan with speed steps.""" + await knx.setup_integration( + { + FanSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + FanSchema.CONF_MAX_STEP: 4, + } + } + ) + assert len(hass.states.async_all()) == 1 + + # turn on fan with default speed (50% - step 2) + await hass.services.async_call( + "fan", "turn_on", {"entity_id": "fan.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (2,)) + + # turn up speed to 75% - step 3 + await hass.services.async_call( + "fan", "turn_on", {"entity_id": "fan.test", "percentage": 75}, blocking=True + ) + await knx.assert_write("1/2/3", (3,)) + + # turn off fan + await hass.services.async_call( + "fan", "turn_off", {"entity_id": "fan.test"}, blocking=True + ) + await knx.assert_write("1/2/3", (0,)) + + # receive step 4 (100%) telegram + await knx.receive_write("1/2/3", (4,)) + state = hass.states.get("fan.test") + assert state.state is STATE_ON + assert state.attributes.get("percentage") == 100 + + # receive step 1 (25%) telegram + await knx.receive_write("1/2/3", (1,)) + state = hass.states.get("fan.test") + assert state.state is STATE_ON + assert state.attributes.get("percentage") == 25 + + # receive step 0 (off) telegram + await knx.receive_write("1/2/3", (0,)) + state = hass.states.get("fan.test") + assert state.state is STATE_OFF + + # fan does not respond to read + await knx.receive_read("1/2/3") + await knx.assert_telegram_count(0) + + +async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX fan oscillation.""" + await knx.setup_integration( + { + FanSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: "1/1/1", + FanSchema.CONF_OSCILLATION_ADDRESS: "2/2/2", + } + } + ) + assert len(hass.states.async_all()) == 1 + + # turn on oscillation + await hass.services.async_call( + "fan", + "oscillate", + {"entity_id": "fan.test", "oscillating": True}, + blocking=True, + ) + await knx.assert_write("2/2/2", True) + + # turn off oscillation + await hass.services.async_call( + "fan", + "oscillate", + {"entity_id": "fan.test", "oscillating": False}, + blocking=True, + ) + await knx.assert_write("2/2/2", False) + + # receive oscillation on + await knx.receive_write("2/2/2", True) + state = hass.states.get("fan.test") + assert state.attributes.get("oscillating") is True + + # receive oscillation off + await knx.receive_write("2/2/2", False) + state = hass.states.get("fan.test") + assert state.attributes.get("oscillating") is False diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py new file mode 100644 index 00000000000..16ea5e8d385 --- /dev/null +++ b/tests/components/knx/test_sensor.py @@ -0,0 +1,95 @@ +"""Test KNX sensor.""" +from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.schema import SensorSchema +from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + +from tests.common import async_capture_events + + +async def test_sensor(hass: HomeAssistant, knx: KNXTestKit): + """Test simple KNX sensor.""" + + await knx.setup_integration( + { + SensorSchema.PLATFORM_NAME: { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "1/1/1", + CONF_TYPE: "current", # 2 byte unsigned int + } + } + ) + assert len(hass.states.async_all()) == 1 + state = hass.states.get("sensor.test") + assert state.state is STATE_UNKNOWN + + # StateUpdater initialize state + await knx.assert_read("1/1/1") + await knx.receive_response("1/1/1", (0, 40)) + state = hass.states.get("sensor.test") + assert state.state == "40" + + # update from KNX + await knx.receive_write("1/1/1", (0x03, 0xE8)) + state = hass.states.get("sensor.test") + assert state.state == "1000" + + # don't answer to GroupValueRead requests + await knx.receive_read("1/1/1") + await knx.assert_no_telegram() + + +async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX sensor with always_callback.""" + + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + SensorSchema.PLATFORM_NAME: [ + { + CONF_NAME: "test_normal", + CONF_STATE_ADDRESS: "1/1/1", + CONF_SYNC_STATE: False, + CONF_TYPE: "percentU8", + }, + { + CONF_NAME: "test_always", + CONF_STATE_ADDRESS: "2/2/2", + SensorSchema.CONF_ALWAYS_CALLBACK: True, + CONF_SYNC_STATE: False, + CONF_TYPE: "percentU8", + }, + ] + } + ) + assert len(hass.states.async_all()) == 2 + # state changes form None to "unknown" + assert len(events) == 2 + + # receive initial telegram + await knx.receive_write("1/1/1", (0x42,)) + await knx.receive_write("2/2/2", (0x42,)) + await hass.async_block_till_done() + assert len(events) == 4 + + # receive second telegram with identical payload + # always_callback shall force state_changed event + await knx.receive_write("1/1/1", (0x42,)) + await knx.receive_write("2/2/2", (0x42,)) + await hass.async_block_till_done() + assert len(events) == 5 + + # receive telegram with different payload + await knx.receive_write("1/1/1", (0xFA,)) + await knx.receive_write("2/2/2", (0xFA,)) + await hass.async_block_till_done() + assert len(events) == 7 + + # receive telegram with second payload again + # always_callback shall force state_changed event + await knx.receive_write("1/1/1", (0xFA,)) + await knx.receive_write("2/2/2", (0xFA,)) + await hass.async_block_till_done() + assert len(events) == 8 diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index d9394ae946e..f3bd4583676 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1161,6 +1161,9 @@ async def test_light_backwards_compatibility_color_mode( state = hass.states.get(entity2.entity_id) assert state.attributes["supported_color_modes"] == [light.COLOR_MODE_COLOR_TEMP] assert state.attributes["color_mode"] == light.COLOR_MODE_COLOR_TEMP + assert state.attributes["rgb_color"] == (201, 218, 255) + assert state.attributes["hs_color"] == (221.575, 20.9) + assert state.attributes["xy_color"] == (0.277, 0.287) state = hass.states.get(entity3.entity_id) assert state.attributes["supported_color_modes"] == [light.COLOR_MODE_HS] diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index a5f5b955882..dbc8c39790c 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -26,6 +26,7 @@ async def test_sleep_time_sensor_with_none_state(hass): sensor = LitterRobotSleepTimeSensor( robot, "Sleep Mode Start Time", Mock(), "sleep_mode_start_time" ) + sensor.hass = hass assert sensor assert sensor.state is None diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py index 1f5cc5fd04f..4032e29b743 100644 --- a/tests/components/mfi/test_sensor.py +++ b/tests/components/mfi/test_sensor.py @@ -121,7 +121,9 @@ def port_fixture(): @pytest.fixture(name="sensor") def sensor_fixture(hass, port): """Sensor fixture.""" - return mfi.MfiSensor(port, hass) + sensor = mfi.MfiSensor(port, hass) + sensor.hass = hass + return sensor async def test_name(port, sensor): diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py index e827b5dfbd2..fd494d6c099 100644 --- a/tests/components/mhz19/test_sensor.py +++ b/tests/components/mhz19/test_sensor.py @@ -43,10 +43,11 @@ async def test_setup_connected(hass): ): read_mh_z19_with_temperature.return_value = None mock_add = Mock() - assert mhz19.setup_platform( + mhz19.setup_platform( hass, { "platform": "mhz19", + "name": "name", "monitored_conditions": ["co2", "temperature"], mhz19.CONF_SERIAL_DEVICE: "test.serial", }, @@ -83,40 +84,42 @@ async def aiohttp_client_update_good_read(mock_function): @patch("pmsensor.co2sensor.read_mh_z19_with_temperature", return_value=(1000, 24)) -async def test_co2_sensor(mock_function): +async def test_co2_sensor(mock_function, hass): """Test CO2 sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_CO2, None, "name") + sensor = mhz19.MHZ19Sensor(client, "name", mhz19.SENSOR_TYPES[1]) + sensor.hass = hass sensor.update() assert sensor.name == "name: CO2" assert sensor.state == 1000 - assert sensor.unit_of_measurement == CONCENTRATION_PARTS_PER_MILLION + assert sensor.native_unit_of_measurement == CONCENTRATION_PARTS_PER_MILLION assert sensor.should_poll assert sensor.extra_state_attributes == {"temperature": 24} @patch("pmsensor.co2sensor.read_mh_z19_with_temperature", return_value=(1000, 24)) -async def test_temperature_sensor(mock_function): +async def test_temperature_sensor(mock_function, hass): """Test temperature sensor.""" client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_TEMPERATURE, None, "name") + sensor = mhz19.MHZ19Sensor(client, "name", mhz19.SENSOR_TYPES[0]) + sensor.hass = hass sensor.update() assert sensor.name == "name: Temperature" assert sensor.state == 24 - assert sensor.unit_of_measurement == TEMP_CELSIUS + assert sensor.native_unit_of_measurement == TEMP_CELSIUS assert sensor.should_poll assert sensor.extra_state_attributes == {"co2_concentration": 1000} @patch("pmsensor.co2sensor.read_mh_z19_with_temperature", return_value=(1000, 24)) -async def test_temperature_sensor_f(mock_function): +async def test_temperature_sensor_f(mock_function, hass): """Test temperature sensor.""" - client = mhz19.MHZClient(co2sensor, "test.serial") - sensor = mhz19.MHZ19Sensor( - client, mhz19.SENSOR_TEMPERATURE, TEMP_FAHRENHEIT, "name" - ) - sensor.update() + with patch.object(hass.config.units, "temperature_unit", TEMP_FAHRENHEIT): + client = mhz19.MHZClient(co2sensor, "test.serial") + sensor = mhz19.MHZ19Sensor(client, "name", mhz19.SENSOR_TYPES[0]) + sensor.hass = hass + sensor.update() - assert sensor.state == 75.2 + assert sensor.state == 75 diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index db960f448ff..7942a8193b3 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -1,4 +1,6 @@ """The tests for the Modbus sensor component.""" +import copy +from dataclasses import dataclass from datetime import timedelta import logging from unittest import mock @@ -6,24 +8,32 @@ from unittest import mock from pymodbus.exceptions import ModbusException import pytest -from homeassistant.components.modbus.const import DEFAULT_HUB, MODBUS_DOMAIN as DOMAIN -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PLATFORM, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_TYPE, -) +from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, mock_restore_cache TEST_MODBUS_NAME = "modbusTest" +TEST_ENTITY_NAME = "test_entity" +TEST_MODBUS_HOST = "modbusHost" +TEST_PORT_TCP = 5501 +TEST_PORT_SERIAL = "usb01" + _LOGGER = logging.getLogger(__name__) +@dataclass +class ReadResult: + """Storage class for register read results.""" + + def __init__(self, register_words): + """Init.""" + self.registers = register_words + self.bits = register_words + + @pytest.fixture def mock_pymodbus(): """Mock pymodbus.""" @@ -40,27 +50,92 @@ def mock_pymodbus(): @pytest.fixture -async def mock_modbus(hass, do_config): +def check_config_loaded(): + """Set default for check_config_loaded.""" + return True + + +@pytest.fixture +def register_words(): + """Set default for register_words.""" + return [0x00, 0x00] + + +@pytest.fixture +def config_addon(): + """Add entra configuration items.""" + return None + + +@pytest.fixture +def do_exception(): + """Remove side_effect to pymodbus calls.""" + return False + + +@pytest.fixture +async def mock_modbus( + hass, caplog, register_words, check_config_loaded, config_addon, do_config +): """Load integration modbus using mocked pymodbus.""" + conf = copy.deepcopy(do_config) + if config_addon: + for key in conf.keys(): + conf[key][0].update(config_addon) + caplog.set_level(logging.WARNING) config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, - **do_config, + **conf, } ] } + mock_pb = mock.MagicMock() with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient", autospec=True - ) as mock_pb: - assert await async_setup_component(hass, DOMAIN, config) is True + "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb + ): + now = dt_util.utcnow() + with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + result = await async_setup_component(hass, DOMAIN, config) + assert result or not check_config_loaded await hass.async_block_till_done() yield mock_pb +@pytest.fixture +async def mock_pymodbus_exception(hass, do_exception, mock_modbus): + """Trigger update call with time_changed event.""" + if do_exception: + exc = ModbusException("fail read_coils") + mock_modbus.read_coils.side_effect = exc + mock_modbus.read_discrete_inputs.side_effect = exc + mock_modbus.read_input_registers.side_effect = exc + mock_modbus.read_holding_registers.side_effect = exc + + +@pytest.fixture +async def mock_pymodbus_return(hass, register_words, mock_modbus): + """Trigger update call with time_changed event.""" + read_result = ReadResult(register_words) + mock_modbus.read_coils.return_value = read_result + mock_modbus.read_discrete_inputs.return_value = read_result + mock_modbus.read_input_registers.return_value = read_result + mock_modbus.read_holding_registers.return_value = read_result + + +@pytest.fixture +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): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + @pytest.fixture async def mock_test_state(hass, request): """Mock restore cache.""" @@ -68,169 +143,8 @@ async def mock_test_state(hass, request): return request.param -# dataclass -class ReadResult: - """Storage class for register read results.""" - - def __init__(self, register_words): - """Init.""" - self.registers = register_words - self.bits = register_words - - -async def base_test( - hass, - config_device, - device_name, - entity_domain, - array_name_discovery, - array_name_old_config, - register_words, - expected, - method_discovery=False, - check_config_only=False, - config_modbus=None, - scan_interval=None, - expect_init_to_fail=False, - expect_setup_to_fail=False, -): - """Run test on device for given config.""" - - if config_modbus is None: - config_modbus = { - DOMAIN: { - CONF_NAME: DEFAULT_HUB, - CONF_TYPE: "tcp", - CONF_HOST: "modbusTest", - CONF_PORT: 5001, - }, - } - - mock_sync = mock.MagicMock() - with mock.patch( - "homeassistant.components.modbus.modbus.ModbusTcpClient", - autospec=True, - return_value=mock_sync, - ): - - # Setup inputs for the sensor - if register_words is None: - mock_sync.read_coils.side_effect = ModbusException("fail read_coils") - mock_sync.read_discrete_inputs.side_effect = ModbusException( - "fail read_coils" - ) - mock_sync.read_input_registers.side_effect = ModbusException( - "fail read_coils" - ) - mock_sync.read_holding_registers.side_effect = ModbusException( - "fail read_coils" - ) - else: - read_result = ReadResult(register_words) - mock_sync.read_coils.return_value = read_result - mock_sync.read_discrete_inputs.return_value = read_result - mock_sync.read_input_registers.return_value = read_result - mock_sync.read_holding_registers.return_value = read_result - - # mock timer and add old/new config - now = dt_util.utcnow() - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - if method_discovery and config_device is not None: - # setup modbus which in turn does setup for the devices - config_modbus[DOMAIN].update( - {array_name_discovery: [{**config_device}]} - ) - config_device = None - assert ( - await async_setup_component(hass, DOMAIN, config_modbus) - is not expect_setup_to_fail - ) - await hass.async_block_till_done() - - # setup platform old style - if config_device is not None: - config_device = { - entity_domain: { - CONF_PLATFORM: DOMAIN, - array_name_old_config: [ - { - **config_device, - } - ], - } - } - if scan_interval is not None: - config_device[entity_domain][CONF_SCAN_INTERVAL] = scan_interval - assert await async_setup_component(hass, entity_domain, config_device) - await hass.async_block_till_done() - - assert (DOMAIN in hass.config.components) is not expect_setup_to_fail - if config_device is not None: - entity_id = f"{entity_domain}.{device_name}" - device = hass.states.get(entity_id) - - if expect_init_to_fail: - assert device is None - elif device is None: - pytest.fail("CONFIG failed, see output") - if check_config_only: - return - - # Trigger update call with time_changed event - now = now + timedelta(seconds=scan_interval + 60) - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() - - # Check state - entity_id = f"{entity_domain}.{device_name}" - return hass.states.get(entity_id).state - - -async def base_config_test( - hass, - config_device, - device_name, - entity_domain, - array_name_discovery, - array_name_old_config, - method_discovery=False, - config_modbus=None, - expect_init_to_fail=False, - expect_setup_to_fail=False, -): - """Check config of device for given config.""" - - await base_test( - hass, - config_device, - device_name, - entity_domain, - array_name_discovery, - array_name_old_config, - None, - None, - method_discovery=method_discovery, - check_config_only=True, - config_modbus=config_modbus, - expect_init_to_fail=expect_init_to_fail, - expect_setup_to_fail=expect_setup_to_fail, - ) - - -async def prepare_service_update(hass, config): - """Run test for service write_coil.""" - - config_modbus = { - DOMAIN: { - CONF_NAME: DEFAULT_HUB, - CONF_TYPE: "tcp", - CONF_HOST: "modbusTest", - CONF_PORT: 5001, - **config, - }, - } - assert await async_setup_component(hass, DOMAIN, config_modbus) - await hass.async_block_till_done() +@pytest.fixture +async def mock_ha(hass, mock_pymodbus_return): + """Load homeassistant to allow service calls.""" assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index e77fd380a22..5de03287592 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,4 +1,4 @@ -"""The tests for the Modbus sensor component.""" +"""Thetests for the Modbus sensor component.""" import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -6,6 +6,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, ) from homeassistant.const import ( CONF_ADDRESS, @@ -20,10 +21,9 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import TEST_ENTITY_NAME, ReadResult -SENSOR_NAME = "test_binary_sensor" -ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" +ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -32,7 +32,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_BINARY_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, } ] @@ -40,11 +40,12 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_BINARY_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", + CONF_LAZY_ERROR: 10, } ] }, @@ -55,80 +56,97 @@ async def test_config_binary_sensor(hass, mock_modbus): assert SENSOR_DOMAIN in hass.config.components -@pytest.mark.parametrize("do_type", [CALL_TYPE_COIL, CALL_TYPE_DISCRETE]) @pytest.mark.parametrize( - "regs,expected", + "do_config", + [ + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_INPUT_TYPE: CALL_TYPE_COIL, + }, + ], + }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,do_exception,expected", [ ( [0xFF], + False, STATE_ON, ), ( [0x01], + False, STATE_ON, ), ( [0x00], + False, STATE_OFF, ), ( [0x80], + False, STATE_OFF, ), ( [0xFE], + False, STATE_OFF, ), ( - None, + [0x00], + True, STATE_UNAVAILABLE, ), ], ) -async def test_all_binary_sensor(hass, do_type, regs, expected): +async def test_all_binary_sensor(hass, expected, mock_do_cycle): """Run test for given config.""" - state = await base_test( - hass, - {CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: do_type}, - SENSOR_NAME, - SENSOR_DOMAIN, - CONF_BINARY_SENSORS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected -async def test_service_binary_sensor_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_COIL, + } + ] + }, + ], +) +async def test_service_binary_sensor_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - config = { - CONF_BINARY_SENSORS: [ - { - CONF_NAME: SENSOR_NAME, - CONF_ADDRESS: 1234, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - } - ] - } - mock_pymodbus.read_coils.return_value = ReadResult([0x00]) - await prepare_service_update( - hass, - config, - ) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_pymodbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) + await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ON @@ -143,7 +161,7 @@ async def test_service_binary_sensor_update(hass, mock_pymodbus): { CONF_BINARY_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, } diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 97d2c32ba69..187c049b069 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -6,6 +6,7 @@ from homeassistant.components.climate.const import HVAC_MODE_AUTO from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, + CONF_LAZY_ERROR, CONF_TARGET_TEMP, DATA_TYPE_FLOAT32, DATA_TYPE_FLOAT64, @@ -22,10 +23,9 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import TEST_ENTITY_NAME, ReadResult -CLIMATE_NAME = "test_climate" -ENTITY_ID = f"{CLIMATE_DOMAIN}.{CLIMATE_NAME}" +ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -34,7 +34,7 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{CLIMATE_NAME}" { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, @@ -44,12 +44,13 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{CLIMATE_NAME}" { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, CONF_COUNT: 2, + CONF_LAZY_ERROR: 10, } ], }, @@ -61,58 +62,53 @@ async def test_config_climate(hass, mock_modbus): @pytest.mark.parametrize( - "regs,expected", + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_SLAVE: 1, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_COUNT: 2, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,expected", [ ( - [0x00], + [0x00, 0x00], "auto", ), ], ) -async def test_temperature_climate(hass, regs, expected): +async def test_temperature_climate(hass, expected, mock_do_cycle): """Run test for given config.""" - CLIMATE_NAME = "modbus_test_climate" - return - state = await base_test( - hass, + assert hass.states.get(ENTITY_ID).state == expected + + +@pytest.mark.parametrize( + "do_config", + [ { - CONF_NAME: CLIMATE_NAME, - CONF_SLAVE: 1, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_COUNT: 2, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + } + ] }, - CLIMATE_NAME, - CLIMATE_DOMAIN, - CONF_CLIMATES, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected - - -async def test_service_climate_update(hass, mock_pymodbus): + ], +) +async def test_service_climate_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_CLIMATES: [ - { - CONF_NAME: CLIMATE_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_SCAN_INTERVAL: 0, - } - ] - } - mock_pymodbus.read_input_registers.return_value = ReadResult([0x00]) - await prepare_service_update( - hass, - config, - ) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -120,34 +116,75 @@ async def test_service_climate_update(hass, mock_pymodbus): @pytest.mark.parametrize( - "data_type, temperature, result", + "temperature, result, do_config", [ - (DATA_TYPE_INT16, 35, [0x00]), - (DATA_TYPE_INT32, 36, [0x00, 0x00]), - (DATA_TYPE_FLOAT32, 37.5, [0x00, 0x00]), - (DATA_TYPE_FLOAT64, "39", [0x00, 0x00, 0x00, 0x00]), + ( + 35, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DATA_TYPE_INT16, + } + ] + }, + ), + ( + 36, + [0x00, 0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DATA_TYPE_INT32, + } + ] + }, + ), + ( + 37.5, + [0x00, 0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DATA_TYPE_FLOAT32, + } + ] + }, + ), + ( + "39", + [0x00, 0x00, 0x00, 0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DATA_TYPE_FLOAT64, + } + ] + }, + ), ], ) async def test_service_climate_set_temperature( - hass, data_type, temperature, result, mock_pymodbus + hass, temperature, result, mock_modbus, mock_ha ): - """Run test for service homeassistant.update_entity.""" - config = { - CONF_CLIMATES: [ - { - CONF_NAME: CLIMATE_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_DATA_TYPE: data_type, - } - ] - } - mock_pymodbus.read_holding_registers.return_value = ReadResult(result) - await prepare_service_update( - hass, - config, - ) + """Test set_temperature.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, "set_temperature", @@ -174,7 +211,7 @@ test_value.attributes = {ATTR_TEMPERATURE: 37} { CONF_CLIMATES: [ { - CONF_NAME: CLIMATE_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SCAN_INTERVAL: 0, diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 8d7e7e39cf8..9c5b08d59b6 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, @@ -29,10 +30,9 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import TEST_ENTITY_NAME, ReadResult -COVER_NAME = "test_cover" -ENTITY_ID = f"{COVER_DOMAIN}.{COVER_NAME}" +ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -41,7 +41,7 @@ ENTITY_ID = f"{COVER_DOMAIN}.{COVER_NAME}" { CONF_COVERS: [ { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: CALL_TYPE_COIL, } @@ -50,11 +50,12 @@ ENTITY_ID = f"{COVER_DOMAIN}.{COVER_NAME}" { CONF_COVERS: [ { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, + CONF_LAZY_ERROR: 10, } ] }, @@ -66,7 +67,22 @@ async def test_config_cover(hass, mock_modbus): @pytest.mark.parametrize( - "regs,expected", + "do_config", + [ + { + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_INPUT_TYPE: CALL_TYPE_COIL, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,expected", [ ( [0x00], @@ -90,30 +106,27 @@ async def test_config_cover(hass, mock_modbus): ), ], ) -async def test_coil_cover(hass, regs, expected): +async def test_coil_cover(hass, expected, mock_do_cycle): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: COVER_NAME, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - }, - COVER_NAME, - COVER_DOMAIN, - CONF_COVERS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( - "regs,expected", + "do_config", + [ + { + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,expected", [ ( [0x00], @@ -137,49 +150,32 @@ async def test_coil_cover(hass, regs, expected): ), ], ) -async def test_register_cover(hass, regs, expected): +async def test_register_cover(hass, expected, mock_do_cycle): """Run test for given config.""" - state = await base_test( - hass, + assert hass.states.get(ENTITY_ID).state == expected + + +@pytest.mark.parametrize( + "do_config", + [ { - CONF_NAME: COVER_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + } + ] }, - COVER_NAME, - COVER_DOMAIN, - CONF_COVERS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected - - -async def test_service_cover_update(hass, mock_pymodbus): + ], +) +async def test_service_cover_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_COVERS: [ - { - CONF_NAME: COVER_NAME, - CONF_ADDRESS: 1234, - CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, - } - ] - } - mock_pymodbus.read_holding_registers.return_value = ReadResult([0x00]) - await prepare_service_update( - hass, - config, - ) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_CLOSED - mock_pymodbus.read_holding_registers.return_value = ReadResult([0x01]) + mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -202,7 +198,7 @@ async def test_service_cover_update(hass, mock_pymodbus): { CONF_COVERS: [ { - CONF_NAME: COVER_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_INPUT_TYPE: CALL_TYPE_COIL, CONF_ADDRESS: 1234, CONF_STATE_OPEN: 1, @@ -223,51 +219,52 @@ async def test_restore_state_cover(hass, mock_test_state, mock_modbus): assert hass.states.get(ENTITY_ID).state == test_state -async def test_service_cover_move(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, + }, + { + CONF_NAME: f"{TEST_ENTITY_NAME}2", + CONF_INPUT_TYPE: CALL_TYPE_COIL, + CONF_ADDRESS: 1235, + CONF_SCAN_INTERVAL: 0, + }, + ] + }, + ], +) +async def test_service_cover_move(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" ENTITY_ID2 = f"{ENTITY_ID}2" - config = { - CONF_COVERS: [ - { - CONF_NAME: COVER_NAME, - CONF_ADDRESS: 1234, - CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 0, - }, - { - CONF_NAME: f"{COVER_NAME}2", - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SCAN_INTERVAL: 0, - }, - ] - } - mock_pymodbus.read_holding_registers.return_value = ReadResult([0x01]) - await prepare_service_update( - hass, - config, - ) + mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) await hass.services.async_call( "cover", "open_cover", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OPEN - mock_pymodbus.read_holding_registers.return_value = ReadResult([0x00]) + mock_modbus.read_holding_registers.return_value = ReadResult([0x00]) await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_CLOSED - mock_pymodbus.reset() - mock_pymodbus.read_holding_registers.side_effect = ModbusException("fail write_") + mock_modbus.reset() + mock_modbus.read_holding_registers.side_effect = ModbusException("fail write_") await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID}, blocking=True ) - assert mock_pymodbus.read_holding_registers.called + assert mock_modbus.read_holding_registers.called assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - mock_pymodbus.read_coils.side_effect = ModbusException("fail write_") + mock_modbus.read_coils.side_effect = ModbusException("fail write_") await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID2}, blocking=True ) diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 13714d6bd0e..b2793d15bff 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -10,11 +10,13 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_FANS, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, + TCP, ) from homeassistant.const import ( CONF_ADDRESS, @@ -33,10 +35,9 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.setup import async_setup_component -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import TEST_ENTITY_NAME, TEST_MODBUS_HOST, TEST_PORT_TCP, ReadResult -FAN_NAME = "test_fan" -ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" +ENTITY_ID = f"{FAN_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -45,7 +46,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, } ] @@ -53,7 +54,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, } @@ -62,11 +63,12 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, + CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, @@ -79,7 +81,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -96,7 +98,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -113,7 +115,7 @@ ENTITY_ID = f"{FAN_DOMAIN}.{FAN_NAME}" { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -129,58 +131,69 @@ async def test_config_fan(hass, mock_modbus): assert FAN_DOMAIN in hass.config.components -@pytest.mark.parametrize("call_type", [CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING]) @pytest.mark.parametrize( - "regs,verify,expected", + "do_config", + [ + { + CONF_FANS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + }, + ], + }, + { + CONF_FANS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,do_exception,config_addon,expected", [ ( [0x00], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( [0x01], + False, {CONF_VERIFY: {}}, STATE_ON, ), ( [0xFE], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( - None, + [0x00], + True, {CONF_VERIFY: {}}, STATE_UNAVAILABLE, ), ( + [0x00], + True, None, - {}, STATE_OFF, ), ], ) -async def test_all_fan(hass, call_type, regs, verify, expected): +async def test_all_fan(hass, mock_do_cycle, expected): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: FAN_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: call_type, - **verify, - }, - FAN_NAME, - FAN_DOMAIN, - CONF_FANS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( @@ -194,7 +207,7 @@ async def test_all_fan(hass, call_type, regs, verify, expected): { CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, } @@ -210,22 +223,22 @@ async def test_restore_state_fan(hass, mock_test_state, mock_modbus): async def test_fan_service_turn(hass, caplog, mock_pymodbus): """Run test for service turn_on/turn_off.""" - ENTITY_ID2 = f"{FAN_DOMAIN}.{FAN_NAME}2" + ENTITY_ID2 = f"{FAN_DOMAIN}.{TEST_ENTITY_NAME}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_FANS: [ { - CONF_NAME: FAN_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, }, { - CONF_NAME: f"{FAN_NAME}2", - CONF_ADDRESS: 17, + CONF_NAME: f"{TEST_ENTITY_NAME}2", + CONF_ADDRESS: 18, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, @@ -277,30 +290,29 @@ async def test_fan_service_turn(hass, caplog, mock_pymodbus): assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE -async def test_service_fan_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_FANS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + } + ] + }, + ], +) +async def test_service_fan_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_FANS: [ - { - CONF_NAME: FAN_NAME, - CONF_ADDRESS: 1234, - CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_VERIFY: {}, - } - ] - } - mock_pymodbus.read_discrete_inputs.return_value = ReadResult([0x01]) - await prepare_service_update( - hass, - config, - ) - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True - ) - assert hass.states.get(ENTITY_ID).state == STATE_ON - mock_pymodbus.read_coils.return_value = ReadResult([0x00]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF + mock_modbus.read_coils.return_value = ReadResult([0x01]) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + assert hass.states.get(ENTITY_ID).state == STATE_ON diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index b9f6420604f..1bb538a886a 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -51,10 +51,16 @@ from homeassistant.components.modbus.const import ( DATA_TYPE_STRING, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, + RTUOVERTCP, + SERIAL, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, + TCP, + UDP, ) from homeassistant.components.modbus.validators import ( + duplicate_entity_validator, + duplicate_modbus_validator, number_validator, struct_validator, ) @@ -79,15 +85,17 @@ from homeassistant.const import ( from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import ReadResult +from .conftest import ( + TEST_ENTITY_NAME, + TEST_MODBUS_HOST, + TEST_MODBUS_NAME, + TEST_PORT_SERIAL, + TEST_PORT_TCP, + ReadResult, +) from tests.common import async_fire_time_changed -TEST_SENSOR_NAME = "testSensor" -TEST_ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_SENSOR_NAME}" -TEST_HOST = "modbusTestHost" -TEST_MODBUS_NAME = "modbusTest" - @pytest.fixture async def mock_modbus_with_pymodbus(hass, caplog, do_config, mock_pymodbus): @@ -128,17 +136,17 @@ async def test_number_validator(): "do_config", [ { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 2, CONF_DATA_TYPE: DATA_TYPE_STRING, }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 2, CONF_DATA_TYPE: DATA_TYPE_INT, }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 2, CONF_DATA_TYPE: DATA_TYPE_INT, CONF_SWAP: CONF_SWAP_BYTE, @@ -157,29 +165,29 @@ async def test_ok_struct_validator(do_config): "do_config", [ { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 8, CONF_DATA_TYPE: DATA_TYPE_INT, }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 8, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 8, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_STRUCTURE: "no good", }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 20, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_STRUCTURE: ">f", }, { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_COUNT: 1, CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_STRUCTURE: ">f", @@ -196,64 +204,150 @@ async def test_exception_struct_validator(do_config): pytest.fail("struct_validator missing exception") +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + }, + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST + "2", + CONF_PORT: TEST_PORT_TCP, + }, + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + }, + { + CONF_NAME: TEST_MODBUS_NAME + "2", + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + }, + ], + ], +) +async def test_duplicate_modbus_validator(do_config): + """Test duplicate modbus validator.""" + duplicate_modbus_validator(do_config) + assert len(do_config) == 1 + + +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 119, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + }, + { + CONF_NAME: TEST_ENTITY_NAME + "2", + CONF_ADDRESS: 117, + }, + ], + } + ], + ], +) +async def test_duplicate_entity_validator(do_config): + """Test duplicate entity validator.""" + duplicate_entity_validator(do_config) + assert len(do_config[0][CONF_SENSORS]) == 1 + + @pytest.mark.parametrize( "do_config", [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, CONF_TIMEOUT: 30, CONF_DELAY: 10, }, { - CONF_TYPE: "udp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: UDP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: "udp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: UDP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, CONF_TIMEOUT: 30, CONF_DELAY: 10, }, { - CONF_TYPE: "rtuovertcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: RTUOVERTCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, }, { - CONF_TYPE: "rtuovertcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: RTUOVERTCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, CONF_TIMEOUT: 30, CONF_DELAY: 10, }, { - CONF_TYPE: "serial", + CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", - CONF_PORT: "usb01", + CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, CONF_MSG_WAIT: 100, }, { - CONF_TYPE: "serial", + CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", - CONF_PORT: "usb01", + CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, CONF_NAME: TEST_MODBUS_NAME, @@ -261,43 +355,43 @@ async def test_exception_struct_validator(do_config): CONF_DELAY: 10, }, { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_DELAY: 5, }, [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, }, { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, - CONF_NAME: TEST_MODBUS_NAME + "2", + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_NAME: f"{TEST_MODBUS_NAME}2", }, { - CONF_TYPE: "serial", + CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", - CONF_PORT: "usb01", + CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, - CONF_NAME: TEST_MODBUS_NAME + "3", + CONF_NAME: f"{TEST_MODBUS_NAME}3", }, ], { # Special test for scan_interval validator with scan_interval: 0 - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_SENSORS: [ { - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 117, CONF_SCAN_INTERVAL: 0, } @@ -320,11 +414,11 @@ SERVICE = "service" [ { CONF_NAME: TEST_MODBUS_NAME, - CONF_TYPE: "serial", + CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, CONF_METHOD: "rtu", - CONF_PORT: "usb01", + CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, }, @@ -425,14 +519,14 @@ async def mock_modbus_read_pymodbus( config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, do_group: [ { CONF_INPUT_TYPE: do_type, - CONF_NAME: TEST_SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: do_scan_interval, } @@ -482,7 +576,7 @@ async def test_pb_read( """Run test for different read.""" # Check state - entity_id = f"{do_domain}.{TEST_SENSOR_NAME}" + entity_id = f"{do_domain}.{TEST_ENTITY_NAME}" state = hass.states.get(entity_id).state assert hass.states.get(entity_id).state @@ -499,9 +593,10 @@ async def test_pymodbus_constructor_fail(hass, caplog): config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, } ] } @@ -512,7 +607,8 @@ async def test_pymodbus_constructor_fail(hass, caplog): mock_pb.side_effect = ModbusException("test no class") assert await async_setup_component(hass, DOMAIN, config) is False await hass.async_block_till_done() - assert caplog.messages[0].startswith("Pymodbus: Modbus Error: test") + message = f"Pymodbus: {TEST_MODBUS_NAME}: Modbus Error: test" + assert caplog.messages[0].startswith(message) assert caplog.records[0].levelname == "ERROR" assert mock_pb.called @@ -522,9 +618,9 @@ async def test_pymodbus_close_fail(hass, caplog, mock_pymodbus): config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, } ] } @@ -543,19 +639,19 @@ async def test_delay(hass, mock_pymodbus): # We "hijiack" a binary_sensor to make a proper blackbox test. test_delay = 15 test_scan_interval = 5 - entity_id = f"{BINARY_SENSOR_DOMAIN}.{TEST_SENSOR_NAME}" + entity_id = f"{BINARY_SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" config = { DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_NAME: TEST_MODBUS_NAME, CONF_DELAY: test_delay, CONF_BINARY_SENSORS: [ { CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_NAME: f"{TEST_SENSOR_NAME}", + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 52, CONF_SCAN_INTERVAL: test_scan_interval, }, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index c7b9b820934..65d42dff987 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -9,11 +9,13 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, + TCP, ) from homeassistant.const import ( CONF_ADDRESS, @@ -33,10 +35,9 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.setup import async_setup_component -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import TEST_ENTITY_NAME, TEST_MODBUS_HOST, TEST_PORT_TCP, ReadResult -LIGHT_NAME = "test_light" -ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" +ENTITY_ID = f"{LIGHT_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -45,7 +46,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, } ] @@ -53,16 +54,17 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_LAZY_ERROR: 10, } ] }, { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -79,7 +81,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -96,7 +98,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -113,7 +115,7 @@ ENTITY_ID = f"{LIGHT_DOMAIN}.{LIGHT_NAME}" { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -129,58 +131,69 @@ async def test_config_light(hass, mock_modbus): assert LIGHT_DOMAIN in hass.config.components -@pytest.mark.parametrize("call_type", [CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING]) @pytest.mark.parametrize( - "regs,verify,expected", + "do_config", + [ + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + }, + ], + }, + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,do_exception,config_addon,expected", [ ( [0x00], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( [0x01], + False, {CONF_VERIFY: {}}, STATE_ON, ), ( [0xFE], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( - None, + [0x00], + True, {CONF_VERIFY: {}}, STATE_UNAVAILABLE, ), ( + [0x00], + True, None, - {}, STATE_OFF, ), ], ) -async def test_all_light(hass, call_type, regs, verify, expected): +async def test_all_light(hass, mock_do_cycle, expected): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: LIGHT_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: call_type, - **verify, - }, - LIGHT_NAME, - LIGHT_DOMAIN, - CONF_LIGHTS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( @@ -194,7 +207,7 @@ async def test_all_light(hass, call_type, regs, verify, expected): { CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, } @@ -213,19 +226,19 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): ENTITY_ID2 = f"{ENTITY_ID}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_LIGHTS: [ { - CONF_NAME: LIGHT_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, }, { - CONF_NAME: f"{LIGHT_NAME}2", - CONF_ADDRESS: 17, + CONF_NAME: f"{TEST_ENTITY_NAME}2", + CONF_ADDRESS: 18, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, @@ -277,30 +290,29 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE -async def test_service_light_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + } + ] + }, + ], +) +async def test_service_light_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_LIGHTS: [ - { - CONF_NAME: LIGHT_NAME, - CONF_ADDRESS: 1234, - CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_VERIFY: {}, - } - ] - } - mock_pymodbus.read_discrete_inputs.return_value = ReadResult([0x01]) - await prepare_service_update( - hass, - config, - ) - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True - ) - assert hass.states.get(ENTITY_ID).state == STATE_ON - mock_pymodbus.read_coils.return_value = ReadResult([0x00]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF + mock_modbus.read_coils.return_value = ReadResult([0x01]) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + assert hass.states.get(ENTITY_ID).state == STATE_ON diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index f01a3ef9da5..0da9d86f262 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,6 +1,4 @@ """The tests for the Modbus sensor component.""" -import logging - import pytest from homeassistant.components.modbus.const import ( @@ -8,9 +6,10 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DATA_TYPE, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_PRECISION, - CONF_REGISTERS, CONF_SCALE, + CONF_STATE_CLASS, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, @@ -22,7 +21,10 @@ from homeassistant.components.modbus.const import ( DATA_TYPE_STRING, DATA_TYPE_UINT, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, +) from homeassistant.const import ( CONF_ADDRESS, CONF_COUNT, @@ -37,10 +39,9 @@ from homeassistant.const import ( ) from homeassistant.core import State -from .conftest import ReadResult, base_config_test, base_test, prepare_service_update +from .conftest import TEST_ENTITY_NAME, ReadResult -SENSOR_NAME = "test_sensor" -ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" +ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -49,7 +50,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, } ] @@ -57,7 +58,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SLAVE: 10, CONF_COUNT: 1, @@ -65,6 +66,8 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" CONF_PRECISION: 0, CONF_SCALE: 1, CONF_OFFSET: 0, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -73,7 +76,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SLAVE: 10, CONF_COUNT: 1, @@ -89,7 +92,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_NONE, @@ -99,7 +102,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_BYTE, @@ -109,7 +112,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD, @@ -119,7 +122,7 @@ ENTITY_ID = f"{SENSOR_DOMAIN}.{SENSOR_NAME}" { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD_BYTE, @@ -133,99 +136,123 @@ async def test_config_sensor(hass, mock_modbus): assert SENSOR_DOMAIN in hass.config.components +@pytest.mark.parametrize("check_config_loaded", [False]) @pytest.mark.parametrize( "do_config,error_message", [ ( { - CONF_ADDRESS: 1234, - CONF_COUNT: 8, - CONF_PRECISION: 2, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_STRUCTURE: ">no struct", + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_COUNT: 8, + CONF_PRECISION: 2, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_STRUCTURE: ">no struct", + }, + ] }, "bad char in struct format", ), ( { - CONF_ADDRESS: 1234, - CONF_COUNT: 2, - CONF_PRECISION: 2, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_STRUCTURE: ">4f", + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_COUNT: 2, + CONF_PRECISION: 2, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_STRUCTURE: ">4f", + }, + ] }, "Structure request 16 bytes, but 2 registers have a size of 4 bytes", ), ( { - CONF_ADDRESS: 1234, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, - CONF_STRUCTURE: "invalid", + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_COUNT: 4, + CONF_SWAP: CONF_SWAP_NONE, + CONF_STRUCTURE: "invalid", + }, + ] }, "bad char in struct format", ), ( { - CONF_ADDRESS: 1234, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, - CONF_STRUCTURE: "", + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_COUNT: 4, + CONF_SWAP: CONF_SWAP_NONE, + CONF_STRUCTURE: "", + }, + ] }, - "Error in sensor test_sensor. The `structure` field can not be empty", + f"Error in sensor {TEST_ENTITY_NAME}. The `structure` field can not be empty", ), ( { - CONF_ADDRESS: 1234, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, - CONF_STRUCTURE: "1s", + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_COUNT: 4, + CONF_SWAP: CONF_SWAP_NONE, + CONF_STRUCTURE: "1s", + }, + ] }, "Structure request 1 bytes, but 4 registers have a size of 8 bytes", ), ( { - CONF_ADDRESS: 1234, - CONF_DATA_TYPE: DATA_TYPE_CUSTOM, - CONF_COUNT: 1, - CONF_STRUCTURE: "2s", - CONF_SWAP: CONF_SWAP_WORD, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_COUNT: 1, + CONF_STRUCTURE: "2s", + CONF_SWAP: CONF_SWAP_WORD, + }, + ] }, - "Error in sensor test_sensor swap(word) not possible due to the registers count: 1, needed: 2", + f"Error in sensor {TEST_ENTITY_NAME} swap(word) not possible due to the registers count: 1, needed: 2", ), ], ) -async def test_config_wrong_struct_sensor( - hass, caplog, do_config, error_message, mock_pymodbus -): +async def test_config_wrong_struct_sensor(hass, error_message, mock_modbus, caplog): """Run test for sensor with wrong struct.""" - - config_sensor = { - CONF_NAME: SENSOR_NAME, - **do_config, - } - caplog.set_level(logging.WARNING) - caplog.clear() - - await base_config_test( - hass, - config_sensor, - SENSOR_NAME, - SENSOR_DOMAIN, - CONF_SENSORS, - None, - method_discovery=True, - expect_setup_to_fail=True, - ) - - assert caplog.text.count(error_message) + messages = str([x.message for x in caplog.get_records("setup")]) + assert error_message in messages @pytest.mark.parametrize( - "cfg,regs,expected", + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "config_addon,register_words,do_exception,expected", [ ( { @@ -236,11 +263,13 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0], + False, "0", ), ( {}, [0x8000], + False, "-32768", ), ( @@ -252,6 +281,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [7], + False, "20", ), ( @@ -263,6 +293,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [7], + False, "34", ), ( @@ -274,6 +305,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 4, }, [7], + False, "34.0000", ), ( @@ -285,6 +317,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [1], + False, "2", ), ( @@ -296,6 +329,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: "1", }, [9], + False, "18.5", ), ( @@ -307,6 +341,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 2, }, [1], + False, "2.40", ), ( @@ -318,6 +353,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 1, }, [2], + False, "-8.3", ), ( @@ -329,6 +365,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0x89AB, 0xCDEF], + False, "-1985229329", ), ( @@ -340,6 +377,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0x89AB, 0xCDEF], + False, str(0x89ABCDEF), ), ( @@ -351,6 +389,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0x89AB, 0xCDEF, 0x0123, 0x4567], + False, "9920249030613615975", ), ( @@ -362,6 +401,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0x0123, 0x4567, 0x89AB, 0xCDEF], + False, "163971058432973793", ), ( @@ -373,6 +413,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0x0123, 0x4567, 0x89AB, 0xCDEF], + False, "163971058432973792", ), ( @@ -385,6 +426,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0x89AB, 0xCDEF], + False, str(0x89ABCDEF), ), ( @@ -397,6 +439,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0x89AB, 0xCDEF], + False, str(0x89ABCDEF), ), ( @@ -409,6 +452,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 5, }, [16286, 1617], + False, "1.23457", ), ( @@ -421,6 +465,7 @@ async def test_config_wrong_struct_sensor( CONF_PRECISION: 0, }, [0x3037, 0x2D30, 0x352D, 0x3230, 0x3230, 0x2031, 0x343A, 0x3335], + False, "07-05-2020 14:35", ), ( @@ -432,7 +477,8 @@ async def test_config_wrong_struct_sensor( CONF_OFFSET: 0, CONF_PRECISION: 0, }, - None, + [0x00], + True, STATE_UNAVAILABLE, ), ( @@ -444,7 +490,8 @@ async def test_config_wrong_struct_sensor( CONF_OFFSET: 0, CONF_PRECISION: 0, }, - None, + [0x00], + True, STATE_UNAVAILABLE, ), ( @@ -454,6 +501,7 @@ async def test_config_wrong_struct_sensor( CONF_SWAP: CONF_SWAP_NONE, }, [0x0102], + False, str(int(0x0102)), ), ( @@ -463,6 +511,7 @@ async def test_config_wrong_struct_sensor( CONF_SWAP: CONF_SWAP_BYTE, }, [0x0201], + False, str(int(0x0102)), ), ( @@ -472,6 +521,7 @@ async def test_config_wrong_struct_sensor( CONF_SWAP: CONF_SWAP_BYTE, }, [0x0102, 0x0304], + False, str(int(0x02010403)), ), ( @@ -481,6 +531,7 @@ async def test_config_wrong_struct_sensor( CONF_SWAP: CONF_SWAP_WORD, }, [0x0102, 0x0304], + False, str(int(0x03040102)), ), ( @@ -490,30 +541,31 @@ async def test_config_wrong_struct_sensor( CONF_SWAP: CONF_SWAP_WORD_BYTE, }, [0x0102, 0x0304], + False, str(int(0x04030201)), ), ], ) -async def test_all_sensor(hass, cfg, regs, expected): +async def test_all_sensor(hass, mock_do_cycle, expected): """Run test for sensor.""" - - state = await base_test( - hass, - {CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 1234, **cfg}, - SENSOR_NAME, - SENSOR_DOMAIN, - CONF_SENSORS, - CONF_REGISTERS, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( - "cfg,regs,expected", + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "config_addon,register_words,expected", [ ( { @@ -548,22 +600,9 @@ async def test_all_sensor(hass, cfg, regs, expected): ), ], ) -async def test_struct_sensor(hass, cfg, regs, expected): +async def test_struct_sensor(hass, mock_do_cycle, expected): """Run test for sensor struct.""" - - state = await base_test( - hass, - {CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 1234, **cfg}, - SENSOR_NAME, - SENSOR_DOMAIN, - CONF_SENSORS, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( @@ -577,7 +616,7 @@ async def test_struct_sensor(hass, cfg, regs, expected): { CONF_SENSORS: [ { - CONF_NAME: SENSOR_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, } @@ -590,27 +629,28 @@ async def test_restore_state_sensor(hass, mock_test_state, mock_modbus): assert hass.states.get(ENTITY_ID).state == mock_test_state[0].state -async def test_service_sensor_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + } + ] + }, + ], +) +async def test_service_sensor_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - config = { - CONF_SENSORS: [ - { - CONF_NAME: SENSOR_NAME, - CONF_ADDRESS: 1234, - CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, - } - ] - } - mock_pymodbus.read_input_registers.return_value = ReadResult([27]) - await prepare_service_update( - hass, - config, - ) + mock_modbus.read_input_registers.return_value = ReadResult([27]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == "27" - mock_pymodbus.read_input_registers.return_value = ReadResult([32]) + mock_modbus.read_input_registers.return_value = ReadResult([32]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index c620429aad2..c14a7169ae0 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -11,11 +11,13 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, MODBUS_DOMAIN, + TCP, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -39,12 +41,11 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import ReadResult, base_test, prepare_service_update +from .conftest import TEST_ENTITY_NAME, TEST_MODBUS_HOST, TEST_PORT_TCP, ReadResult from tests.common import async_fire_time_changed -SWITCH_NAME = "test_switch" -ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" +ENTITY_ID = f"{SWITCH_DOMAIN}.{TEST_ENTITY_NAME}" @pytest.mark.parametrize( @@ -53,7 +54,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, } ] @@ -61,16 +62,17 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_LAZY_ERROR: 10, } ] }, { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -88,7 +90,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -107,7 +109,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -125,7 +127,7 @@ ENTITY_ID = f"{SWITCH_DOMAIN}.{SWITCH_NAME}" { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, @@ -143,58 +145,69 @@ async def test_config_switch(hass, mock_modbus): assert SWITCH_DOMAIN in hass.config.components -@pytest.mark.parametrize("call_type", [CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING]) @pytest.mark.parametrize( - "regs,verify,expected", + "do_config", + [ + { + CONF_SWITCHES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + }, + ], + }, + { + CONF_SWITCHES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + "register_words,do_exception,config_addon,expected", [ ( [0x00], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( [0x01], + False, {CONF_VERIFY: {}}, STATE_ON, ), ( [0xFE], + False, {CONF_VERIFY: {}}, STATE_OFF, ), ( - None, + [0x00], + True, {CONF_VERIFY: {}}, STATE_UNAVAILABLE, ), ( + [0x00], + True, None, - {}, STATE_OFF, ), ], ) -async def test_all_switch(hass, call_type, regs, verify, expected): +async def test_all_switch(hass, mock_do_cycle, expected): """Run test for given config.""" - state = await base_test( - hass, - { - CONF_NAME: SWITCH_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: call_type, - **verify, - }, - SWITCH_NAME, - SWITCH_DOMAIN, - CONF_SWITCHES, - None, - regs, - expected, - method_discovery=True, - scan_interval=5, - ) - assert state == expected + assert hass.states.get(ENTITY_ID).state == expected @pytest.mark.parametrize( @@ -208,7 +221,7 @@ async def test_all_switch(hass, call_type, regs, verify, expected): { CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, } @@ -224,22 +237,22 @@ async def test_restore_state_switch(hass, mock_test_state, mock_modbus): async def test_switch_service_turn(hass, caplog, mock_pymodbus): """Run test for service turn_on/turn_off.""" - ENTITY_ID2 = f"{SWITCH_DOMAIN}.{SWITCH_NAME}2" + ENTITY_ID2 = f"{SWITCH_DOMAIN}.{TEST_ENTITY_NAME}2" config = { MODBUS_DOMAIN: { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, }, { - CONF_NAME: f"{SWITCH_NAME}2", - CONF_ADDRESS: 17, + CONF_NAME: f"{TEST_ENTITY_NAME}2", + CONF_ADDRESS: 18, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, @@ -291,33 +304,32 @@ async def test_switch_service_turn(hass, caplog, mock_pymodbus): assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE -async def test_service_switch_update(hass, mock_pymodbus): +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SWITCHES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + } + ] + }, + ], +) +async def test_service_switch_update(hass, mock_modbus, mock_ha): """Run test for service homeassistant.update_entity.""" - - config = { - CONF_SWITCHES: [ - { - CONF_NAME: SWITCH_NAME, - CONF_ADDRESS: 1234, - CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_VERIFY: {}, - } - ] - } - mock_pymodbus.read_discrete_inputs.return_value = ReadResult([0x01]) - await prepare_service_update( - hass, - config, - ) - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True - ) - assert hass.states.get(ENTITY_ID).state == STATE_ON - mock_pymodbus.read_coils.return_value = ReadResult([0x00]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF + mock_modbus.read_coils.return_value = ReadResult([0x01]) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + assert hass.states.get(ENTITY_ID).state == STATE_ON async def test_delay_switch(hass, mock_pymodbus): @@ -325,12 +337,12 @@ async def test_delay_switch(hass, mock_pymodbus): config = { MODBUS_DOMAIN: [ { - CONF_TYPE: "tcp", - CONF_HOST: "modbusTestHost", - CONF_PORT: 5501, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, CONF_SWITCHES: [ { - CONF_NAME: SWITCH_NAME, + CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, CONF_VERIFY: { diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index c9d06ef343e..e01b246b8af 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -10,10 +10,19 @@ from homeassistant.components.mqtt.alarm_control_panel import ( MQTT_ALARM_ATTRIBUTES_BLOCKED, ) 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, + SERVICE_ALARM_DISARM, 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_DISARMING, @@ -124,6 +133,7 @@ async def test_update_state_via_state_topic(hass, mqtt_mock): STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_PENDING, STATE_ALARM_ARMING, @@ -151,8 +161,19 @@ async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_UNKNOWN -async def test_arm_home_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" +@pytest.mark.parametrize( + "service,payload", + [ + (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (SERVICE_ALARM_DISARM, "DISARM"), + ], +) +async def test_publish_mqtt_no_code(hass, mqtt_mock, service, payload): + """Test publishing of MQTT messages when no code is configured.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -160,238 +181,121 @@ async def test_arm_home_publishes_mqtt(hass, mqtt_mock): ) await hass.async_block_till_done() - await common.async_alarm_arm_home(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_HOME", 0, False + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, ) + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) -async def test_arm_home_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_mock): - """Test not publishing of MQTT messages with invalid. - When code_arm_required = True - """ +@pytest.mark.parametrize( + "service,payload", + [ + (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (SERVICE_ALARM_DISARM, "DISARM"), + ], +) +async def test_publish_mqtt_with_code(hass, mqtt_mock, service, payload): + """Test publishing of MQTT messages when code is configured.""" assert await async_setup_component( hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) - + await hass.async_block_till_done() call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_home(hass, "abcd") + + # No code provided, should not publish + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, + ) assert mqtt_mock.async_publish.call_count == call_count - -async def test_arm_home_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False - """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code_arm_required"] = False - assert await async_setup_component( - hass, + # Wrong code provided, should not publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - config, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "abcd"}, + blocking=True, ) - await hass.async_block_till_done() - - await common.async_alarm_arm_home(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_HOME", 0, False - ) - - -async def test_arm_away_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_away(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_AWAY", 0, False - ) - - -async def test_arm_away_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_mock): - """Test not publishing of MQTT messages with invalid code. - - When code_arm_required = True - """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_away(hass, "abcd") assert mqtt_mock.async_publish.call_count == call_count - -async def test_arm_away_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False - """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code_arm_required"] = False - assert await async_setup_component( - hass, + # Correct code provided, should publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - config, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_away(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_AWAY", 0, False + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "0123"}, + blocking=True, ) + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) -async def test_arm_night_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_night(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_NIGHT", 0, False - ) - - -async def test_arm_night_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_mock): - """Test not publishing of MQTT messages with invalid code. - - When code_arm_required = True - """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_night(hass, "abcd") - assert mqtt_mock.async_publish.call_count == call_count - - -async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False - """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code_arm_required"] = False - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - config, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_night(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_NIGHT", 0, False - ) - - -async def test_arm_custom_bypass_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while armed.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - } - }, - ) - await hass.async_block_till_done() - - await common.async_alarm_arm_custom_bypass(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_CUSTOM_BYPASS", 0, False - ) - - -async def test_arm_custom_bypass_not_publishes_mqtt_with_invalid_code_when_req( - hass, mqtt_mock +@pytest.mark.parametrize( + "service,payload,disable_code", + [ + (SERVICE_ALARM_ARM_HOME, "ARM_HOME", "code_arm_required"), + (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY", "code_arm_required"), + (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT", "code_arm_required"), + (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION", "code_arm_required"), + (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS", "code_arm_required"), + (SERVICE_ALARM_DISARM, "DISARM", "code_disarm_required"), + ], +) +async def test_publish_mqtt_with_code_required_false( + hass, mqtt_mock, service, payload, disable_code ): - """Test not publishing of MQTT messages with invalid code. + """Test publishing of MQTT messages when code is configured. - When code_arm_required = True + code_arm_required = False / code_disarm_required = false """ + config = copy.deepcopy(DEFAULT_CONFIG_CODE) + config[alarm_control_panel.DOMAIN][disable_code] = False assert await async_setup_component( hass, alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": True, - } - }, + config, ) await hass.async_block_till_done() - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_arm_custom_bypass(hass, "abcd") - assert mqtt_mock.async_publish.call_count == call_count - - -async def test_arm_custom_bypass_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages. - - When code_arm_required = False - """ - assert await async_setup_component( - hass, + # No code provided, should publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": False, - } - }, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, ) - await hass.async_block_till_done() + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) + mqtt_mock.reset_mock() - await common.async_alarm_arm_custom_bypass(hass) - mqtt_mock.async_publish.assert_called_once_with( - "alarm/command", "ARM_CUSTOM_BYPASS", 0, False - ) - - -async def test_disarm_publishes_mqtt(hass, mqtt_mock): - """Test publishing of MQTT messages while disarmed.""" - assert await async_setup_component( - hass, + # Wrong code provided, should publish + await hass.services.async_call( alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "abcd"}, + blocking=True, ) - await hass.async_block_till_done() + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) + mqtt_mock.reset_mock() - await common.async_alarm_disarm(hass) - mqtt_mock.async_publish.assert_called_once_with("alarm/command", "DISARM", 0, False) + # Correct code provided, should publish + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "0123"}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with("alarm/command", payload, 0, False) + mqtt_mock.reset_mock() async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock): @@ -417,41 +321,6 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock): ) -async def test_disarm_publishes_mqtt_when_code_not_req(hass, mqtt_mock): - """Test publishing of MQTT messages while disarmed. - - When code_disarm_required = False - """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[alarm_control_panel.DOMAIN]["code"] = "1234" - config[alarm_control_panel.DOMAIN]["code_disarm_required"] = False - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - config, - ) - await hass.async_block_till_done() - - await common.async_alarm_disarm(hass) - mqtt_mock.async_publish.assert_called_once_with("alarm/command", "DISARM", 0, False) - - -async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_mock): - """Test not publishing of MQTT messages with invalid code. - - When code_disarm_required = True - """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - - call_count = mqtt_mock.async_publish.call_count - await common.async_alarm_disarm(hass, "abcd") - assert mqtt_mock.async_publish.call_count == call_count - - async def test_update_state_via_state_topic_template(hass, mqtt_mock): """Test updating with template_value via state topic.""" assert await async_setup_component( diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 55bacb0ef91..e00e959e606 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components import mqtt +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -100,7 +101,7 @@ async def test_user_single_instance(hass): assert result["reason"] == "single_instance_allowed" -async def test_hassio_single_instance(hass): +async def test_hassio_already_configured(hass): """Test we only allow a single config flow.""" MockConfigEntry(domain="mqtt").add_to_hass(hass) @@ -108,7 +109,23 @@ async def test_hassio_single_instance(hass): "mqtt", context={"source": config_entries.SOURCE_HASSIO} ) assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" + + +async def test_hassio_ignored(hass: HomeAssistant) -> None: + """Test we supervisor discovered instance can be ignored.""" + MockConfigEntry( + domain=mqtt.DOMAIN, source=config_entries.SOURCE_IGNORE + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + mqtt.DOMAIN, + data={"addon": "Mosquitto", "host": "mock-mosquitto", "port": "1883"}, + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" async def test_hassio_confirm(hass, mock_try_connection, mock_finish_setup): diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 438ae0978c6..0501927d003 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -77,9 +77,6 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): "payload_on": "StAtE_On", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", "payload_oscillation_off": "OsC_OfF", "payload_oscillation_on": "OsC_On", "percentage_state_topic": "percentage-state-topic", @@ -96,12 +93,6 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): ], "speed_range_min": 1, "speed_range_max": 200, - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low"], - "payload_off_speed": "speed_OfF", - "payload_low_speed": "speed_lOw", - "payload_medium_speed": "speed_mEdium", - "payload_high_speed": "speed_High", "payload_reset_percentage": "rEset_percentage", "payload_reset_preset_mode": "rEset_preset_mode", } @@ -180,34 +171,11 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): assert "not a valid preset mode" in caplog.text caplog.clear() - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get("speed") == fan.SPEED_OFF - - async_fire_mqtt_message(hass, "speed-state-topic", "speed_lOw") - state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_LOW - - async_fire_mqtt_message(hass, "speed-state-topic", "speed_mEdium") - assert "not a valid speed" in caplog.text - caplog.clear() - - async_fire_mqtt_message(hass, "speed-state-topic", "speed_High") - assert "not a valid speed" in caplog.text - caplog.clear() - - async_fire_mqtt_message(hass, "speed-state-topic", "speed_OfF") - state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_OFF - async_fire_mqtt_message(hass, "percentage-state-topic", "rEset_percentage") state = hass.states.get("fan.test") assert state.attributes.get(fan.ATTR_PERCENTAGE) is None assert state.attributes.get(fan.ATTR_SPEED) is None - async_fire_mqtt_message(hass, "speed-state-topic", "speed_very_high") - assert "not a valid speed" in caplog.text - caplog.clear() - async def test_controlling_state_via_topic_with_different_speed_range( hass, mqtt_mock, caplog @@ -284,9 +252,6 @@ async def test_controlling_state_via_topic_no_percentage_topics( "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", "preset_mode_state_topic": "preset-mode-state-topic", "preset_mode_command_topic": "preset-mode-command-topic", "preset_modes": [ @@ -296,8 +261,6 @@ async def test_controlling_state_via_topic_no_percentage_topics( "eco", "breeze", ], - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low", "medium"], } }, ) @@ -311,22 +274,16 @@ async def test_controlling_state_via_topic_no_percentage_topics( state = hass.states.get("fan.test") assert state.attributes.get("preset_mode") == "smart" assert state.attributes.get(fan.ATTR_PERCENTAGE) is None - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get("speed") == fan.SPEED_OFF async_fire_mqtt_message(hass, "preset-mode-state-topic", "auto") state = hass.states.get("fan.test") assert state.attributes.get("preset_mode") == "auto" assert state.attributes.get(fan.ATTR_PERCENTAGE) is None - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get("speed") == fan.SPEED_OFF async_fire_mqtt_message(hass, "preset-mode-state-topic", "whoosh") state = hass.states.get("fan.test") assert state.attributes.get("preset_mode") == "whoosh" assert state.attributes.get(fan.ATTR_PERCENTAGE) is None - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get("speed") == fan.SPEED_OFF async_fire_mqtt_message(hass, "preset-mode-state-topic", "medium") assert "not a valid preset mode" in caplog.text @@ -336,25 +293,6 @@ async def test_controlling_state_via_topic_no_percentage_topics( assert "not a valid preset mode" in caplog.text caplog.clear() - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - async_fire_mqtt_message(hass, "speed-state-topic", "medium") - state = hass.states.get("fan.test") - assert state.attributes.get("preset_mode") == "whoosh" - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 - assert state.attributes.get("speed") == fan.SPEED_MEDIUM - - async_fire_mqtt_message(hass, "speed-state-topic", "low") - state = hass.states.get("fan.test") - assert state.attributes.get("preset_mode") == "whoosh" - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 50 - assert state.attributes.get("speed") == fan.SPEED_LOW - - async_fire_mqtt_message(hass, "speed-state-topic", "off") - state = hass.states.get("fan.test") - assert state.attributes.get("preset_mode") == "whoosh" - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get("speed") == fan.SPEED_OFF - async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, caplog): """Test the controlling state via topic and JSON message (percentage mode).""" @@ -558,22 +496,13 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): "oscillation_command_topic": "oscillation-command-topic", "payload_oscillation_off": "OsC_OfF", "payload_oscillation_on": "OsC_On", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", "percentage_command_topic": "percentage-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low", "medium"], "preset_modes": [ "whoosh", "breeze", "silent", ], - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "payload_off_speed": "speed_OfF", - "payload_low_speed": "speed_lOw", - "payload_medium_speed": "speed_mEdium", - "payload_high_speed": "speed_High", } }, ) @@ -625,10 +554,8 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): await common.async_set_percentage(hass, "fan.test", 101) await common.async_set_percentage(hass, "fan.test", 100) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) - mqtt_mock.async_publish.assert_any_call( - "speed-command-topic", "speed_mEdium", 0, False + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "100", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") @@ -636,29 +563,18 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 0) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "0", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call( - "speed-command-topic", "speed_OfF", 0, False + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "0", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) == fan.SPEED_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_preset_mode(hass, "fan.test", "low") assert "not a valid preset mode" in caplog.text caplog.clear() - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_preset_mode(hass, "fan.test", "medium") - assert "not a valid preset mode" in caplog.text - caplog.clear() - await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( "preset-mode-command-topic", "whoosh", 0, False @@ -686,41 +602,6 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): assert state.attributes.get(fan.ATTR_PRESET_MODE) == "silent" assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "speed_lOw", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "speed_mEdium", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - assert "not a valid speed" in caplog.text - caplog.clear() - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "speed_OfF", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - async def test_sending_mqtt_commands_with_alternate_speed_range(hass, mqtt_mock): """Test the controlling state via topic using an alternate speed range.""" @@ -888,7 +769,6 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy(hass, mqtt_mock, c mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_preset_mode(hass, "fan.test", "low") @@ -1028,7 +908,6 @@ async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_preset_mode(hass, "fan.test", "low") @@ -1111,13 +990,8 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( "platform": "mqtt", "name": "test", "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "preset_mode_state_topic": "preset-mode-state-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low", "medium"], "preset_modes": [ "whoosh", "breeze", @@ -1133,32 +1007,6 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(MultipleInvalid): - await common.async_set_percentage(hass, "fan.test", -1) - - with pytest.raises(MultipleInvalid): - await common.async_set_percentage(hass, "fan.test", 101) - - await common.async_set_percentage(hass, "fan.test", 100) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_percentage(hass, "fan.test", 0) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get(ATTR_ASSUMED_STATE) - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_preset_mode(hass, "fan.test", "low") - assert "not a valid preset mode" in caplog.text - caplog.clear() - await common.async_set_preset_mode(hass, "fan.test", "medium") assert "not a valid preset mode" in caplog.text caplog.clear() @@ -1190,185 +1038,6 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.attributes.get(fan.ATTR_PRESET_MODE) is None assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "low", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "medium", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - assert "not a valid speed" in caplog.text - caplog.clear() - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_on(hass, "fan.test", speed="medium") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_turn_on(hass, "fan.test", speed="high") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - mqtt_mock.async_publish.assert_any_call( - "preset-mode-command-topic", "high", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - -# use of speeds is deprecated, support will be removed after a quarter (2021.7) -async def test_sending_mqtt_commands_and_optimistic_legacy_speeds_only( - hass, mqtt_mock, caplog -): - """Test optimistic mode without state topics with legacy speeds.""" - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "command-topic", - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", - "speeds": ["off", "low", "medium", "high"], - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_percentage(hass, "fan.test", 100) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "high", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 - assert state.attributes.get(fan.ATTR_SPEED) == "off" - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_percentage(hass, "fan.test", 0) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_preset_mode(hass, "fan.test", "low") - assert "not a valid preset mode" in caplog.text - caplog.clear() - - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "low", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "medium", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "high", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "off", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_on(hass, "fan.test", speed="medium") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_on(hass, "fan.test", speed="off") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, caplog): """Test optimistic mode with state topic and turn on attributes.""" @@ -1381,17 +1050,12 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", "percentage_state_topic": "percentage-state-topic", "percentage_command_topic": "percentage-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "preset_mode_state_topic": "preset-mode-state-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speeds": ["off", "low", "medium"], "preset_modes": [ "whoosh", "breeze", @@ -1421,30 +1085,10 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - await common.async_turn_on(hass, "fan.test", speed=fan.SPEED_MEDIUM) - assert mqtt_mock.async_publish.call_count == 3 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_off(hass, "fan.test") - mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - await common.async_turn_on(hass, "fan.test", percentage=25) - assert mqtt_mock.async_publish.call_count == 3 + assert mqtt_mock.async_publish.call_count == 2 mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "25", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_ON @@ -1457,7 +1101,6 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) with pytest.raises(NotValidPresetModeError): await common.async_turn_on(hass, "fan.test", preset_mode="auto") @@ -1525,11 +1168,9 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_on(hass, "fan.test", percentage=50) - assert mqtt_mock.async_publish.call_count == 3 + assert mqtt_mock.async_publish.call_count == 2 mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "50", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_ON @@ -1552,40 +1193,36 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 33) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "33", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "33", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 50) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "50", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "50", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 100) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "100", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_percentage(hass, "fan.test", 0) - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "0", 0, False) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "0", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") assert state.state == STATE_OFF @@ -1629,33 +1266,6 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "medium", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - assert "not a valid speed" in caplog.text - caplog.clear() - - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "off", 0, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_set_speed(hass, "fan.test", "cUsToM") - assert "not a valid speed" in caplog.text - caplog.clear() - async def test_attributes(hass, mqtt_mock, caplog): """Test attributes.""" @@ -1668,8 +1278,6 @@ async def test_attributes(hass, mqtt_mock, caplog): "name": "test", "command_topic": "command-topic", "oscillation_command_topic": "oscillation-command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "percentage_command_topic": "percentage-command-topic", "preset_modes": [ @@ -1683,104 +1291,31 @@ async def test_attributes(hass, mqtt_mock, caplog): state = hass.states.get("fan.test") assert state.state == STATE_OFF - assert state.attributes.get(fan.ATTR_SPEED_LIST) == [ - "low", - "medium", - "high", - ] await common.async_turn_on(hass, "fan.test") state = hass.states.get("fan.test") assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is None await common.async_turn_off(hass, "fan.test") state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is None await common.async_oscillate(hass, "fan.test", True) state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is True await common.async_oscillate(hass, "fan.test", False) state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is False - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "low" - assert state.attributes.get(fan.ATTR_OSCILLATING) is False - - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "medium" - assert state.attributes.get(fan.ATTR_OSCILLATING) is False - - await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "high" - assert state.attributes.get(fan.ATTR_OSCILLATING) is False - - await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "off" - assert state.attributes.get(fan.ATTR_OSCILLATING) is False - - await common.async_set_speed(hass, "fan.test", "cUsToM") - assert "not a valid speed" in caplog.text - caplog.clear() - - -# use of speeds is deprecated, support will be removed after a quarter (2021.7) -async def test_custom_speed_list(hass, mqtt_mock): - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "oscillation_state_topic": "oscillation-state-topic", - "speed_command_topic": "speed-command-topic", - "speed_state_topic": "speed-state-topic", - "speeds": ["off", "high"], - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state.state == STATE_OFF - assert state.attributes.get(fan.ATTR_SPEED_LIST) == ["high"] - async def test_supported_features(hass, mqtt_mock): """Test optimistic mode without state topic.""" @@ -1800,29 +1335,6 @@ async def test_supported_features(hass, mqtt_mock): "command_topic": "command-topic", "oscillation_command_topic": "oscillation-command-topic", }, - { - "platform": "mqtt", - "name": "test3a1", - "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", - }, - { - "platform": "mqtt", - "name": "test3a2", - "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", - "speeds": ["low"], - }, - { - "platform": "mqtt", - "name": "test3a3", - "command_topic": "command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", - "speeds": ["off"], - }, { "platform": "mqtt", "name": "test3b", @@ -1849,14 +1361,6 @@ async def test_supported_features(hass, mqtt_mock): "preset_mode_command_topic": "preset-mode-command-topic", "preset_modes": ["eco", "smart", "auto"], }, - { - "platform": "mqtt", - "name": "test4", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - # use of speeds is deprecated, support will be removed after a quarter (2021.7) - "speed_command_topic": "speed-command-topic", - }, { "platform": "mqtt", "name": "test4pcta", @@ -1941,19 +1445,6 @@ async def test_supported_features(hass, mqtt_mock): state = hass.states.get("fan.test2") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_OSCILLATE - state = hass.states.get("fan.test3a1") - assert ( - state.attributes.get(ATTR_SUPPORTED_FEATURES) - and fan.SUPPORT_SET_SPEED == fan.SUPPORT_SET_SPEED - ) - state = hass.states.get("fan.test3a2") - assert ( - state.attributes.get(ATTR_SUPPORTED_FEATURES) - and fan.SUPPORT_SET_SPEED == fan.SUPPORT_SET_SPEED - ) - state = hass.states.get("fan.test3a3") - assert state is None - state = hass.states.get("fan.test3b") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_SET_SPEED @@ -1965,12 +1456,6 @@ async def test_supported_features(hass, mqtt_mock): state = hass.states.get("fan.test3c3") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_PRESET_MODE - state = hass.states.get("fan.test4") - assert ( - state.attributes.get(ATTR_SUPPORTED_FEATURES) - == fan.SUPPORT_OSCILLATE | fan.SUPPORT_SET_SPEED - ) - state = hass.states.get("fan.test4pcta") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_SET_SPEED state = hass.states.get("fan.test4pctb") diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 7341eeb67fc..ae6a58c7d22 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -393,7 +393,7 @@ async def test_legacy_controlling_state_via_topic(hass, mqtt_mock): async_fire_mqtt_message(hass, "test_light_rgb/rgb/status", "125,125,125") light_state = hass.states.get("light.test") - assert light_state.attributes.get("rgb_color") is None + assert light_state.attributes.get("rgb_color") == (255, 187, 131) assert light_state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes @@ -636,13 +636,13 @@ async def test_legacy_invalid_state_via_topic(hass, mqtt_mock, caplog): state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgb_color") == (255, 254, 250) assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp") == 153 assert state.attributes.get("effect") == "none" - assert state.attributes.get("hs_color") is None + assert state.attributes.get("hs_color") == (54.768, 1.6) assert state.attributes.get("white_value") == 255 - assert state.attributes.get("xy_color") is None + assert state.attributes.get("xy_color") == (0.326, 0.333) async_fire_mqtt_message(hass, "test_light_rgb/color_temp/status", "") assert "Ignoring empty color temp message" in caplog.text @@ -776,12 +776,12 @@ async def test_invalid_state_via_topic(hass, mqtt_mock, caplog): state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgb_color") == (255, 254, 250) assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp") == 153 assert state.attributes.get("effect") == "none" - assert state.attributes.get("hs_color") is None - assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") == (54.768, 1.6) + assert state.attributes.get("xy_color") == (0.326, 0.333) async_fire_mqtt_message(hass, "test_light_rgb/color_temp/status", "") assert "Ignoring empty color temp message" in caplog.text @@ -988,7 +988,7 @@ async def test_legacy_controlling_state_via_topic_with_templates(hass, mqtt_mock state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 50 - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgb_color") == (255, 187, 131) assert state.attributes.get("color_temp") == 300 assert state.attributes.get("effect") == "rainbow" assert state.attributes.get("white_value") == 75 @@ -1260,11 +1260,11 @@ async def test_legacy_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgb_color") == (221, 229, 255) assert state.attributes["brightness"] == 50 - assert state.attributes.get("hs_color") is None + assert state.attributes.get("hs_color") == (224.772, 13.249) assert state.attributes["white_value"] == 80 - assert state.attributes.get("xy_color") is None + assert state.attributes.get("xy_color") == (0.296, 0.301) assert state.attributes["color_temp"] == 125 diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 8aba08f60d7..bf0dd1880a4 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -392,7 +392,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color":null}') light_state = hass.states.get("light.test") - assert "hs_color" not in light_state.attributes + assert "hs_color" in light_state.attributes async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":155}') diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 15ca9870077..46c06f0d3b3 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -208,7 +208,7 @@ async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock): assert state.state == "100" -async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): +async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock, caplog): """Test the setting of the last_reset property via MQTT.""" assert await async_setup_component( hass, @@ -228,6 +228,11 @@ async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" + assert "'last_reset_topic' must be same as 'state_topic'" in caplog.text + assert ( + "'last_reset_value_template' must be set if 'last_reset_topic' is set" + in caplog.text + ) @pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) @@ -306,6 +311,45 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" +@pytest.mark.parametrize("extra", [{}, {"last_reset_topic": "test-topic"}]) +async def test_setting_sensor_last_reset_via_mqtt_json_message_2( + hass, mqtt_mock, caplog, extra +): + """Test the setting of the value via MQTT with JSON payload.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + **{ + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + }, + **extra, + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message( + hass, + "test-topic", + '{"type":"minute","time":1629385500000,"value":947.7706166666667}', + ) + state = hass.states.get("sensor.test") + assert float(state.state) == pytest.approx(0.015796176944444445) + assert state.attributes.get("last_reset") == "2021-08-19T15:05:00+00:00" + assert "'last_reset_topic' must be same as 'state_topic'" not in caplog.text + assert ( + "'last_reset_value_template' must be set if 'last_reset_topic' is set" + not in caplog.text + ) + + async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component( diff --git a/tests/components/myq/test_light.py b/tests/components/myq/test_light.py new file mode 100644 index 00000000000..c7b3dbc8427 --- /dev/null +++ b/tests/components/myq/test_light.py @@ -0,0 +1,36 @@ +"""The scene tests for the myq platform.""" + +from homeassistant.const import STATE_OFF, STATE_ON + +from .util import async_init_integration + + +async def test_create_lights(hass): + """Test creation of lights.""" + + await async_init_integration(hass) + + state = hass.states.get("light.garage_door_light_off") + assert state.state == STATE_OFF + expected_attributes = { + "friendly_name": "Garage Door Light Off", + "supported_features": 0, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) + + state = hass.states.get("light.garage_door_light_on") + assert state.state == STATE_ON + expected_attributes = { + "friendly_name": "Garage Door Light On", + "supported_features": 0, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 49c32301442..1843e495801 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -3,13 +3,14 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Generator import json -from typing import Any +from typing import Any, Callable from unittest.mock import MagicMock, patch from mysensors.persistence import MySensorsJSONDecoder from mysensors.sensor import Sensor import pytest +from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.mysensors import CONF_VERSION, DEFAULT_BAUD_RATE from homeassistant.components.mysensors.const import ( @@ -27,14 +28,14 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture(autouse=True) -def device_tracker_storage(mock_device_tracker_conf): +def device_tracker_storage(mock_device_tracker_conf: list[Device]) -> list[Device]: """Mock out device tracker known devices storage.""" devices = mock_device_tracker_conf return devices @pytest.fixture(name="mqtt") -def mock_mqtt_fixture(hass) -> None: +def mock_mqtt_fixture(hass: HomeAssistant) -> None: """Mock the MQTT integration.""" hass.config.components.add(MQTT_DOMAIN) @@ -75,14 +76,14 @@ def mock_gateway_features( ) -> None: """Mock the gateway features.""" - async def mock_start_persistence(): + async def mock_start_persistence() -> None: """Load nodes from via persistence.""" gateway = transport_class.call_args[0][0] gateway.sensors.update(nodes) tasks.start_persistence.side_effect = mock_start_persistence - async def mock_start(): + async def mock_start() -> None: """Mock the start method.""" gateway = transport_class.call_args[0][0] gateway.on_conn_made(gateway) @@ -97,7 +98,7 @@ def transport_fixture(serial_transport: MagicMock) -> MagicMock: @pytest.fixture(name="serial_entry") -async def serial_entry_fixture(hass) -> MockConfigEntry: +async def serial_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: """Create a config entry for a serial gateway.""" entry = MockConfigEntry( domain=DOMAIN, @@ -120,15 +121,25 @@ def config_entry_fixture(serial_entry: MockConfigEntry) -> MockConfigEntry: @pytest.fixture async def integration( hass: HomeAssistant, transport: MagicMock, config_entry: MockConfigEntry -) -> AsyncGenerator[MockConfigEntry, None]: +) -> AsyncGenerator[tuple[MockConfigEntry, Callable[[str], None]], None]: """Set up the mysensors integration with a config entry.""" device = config_entry.data[CONF_DEVICE] config: dict[str, Any] = {DOMAIN: {CONF_GATEWAYS: [{CONF_DEVICE: device}]}} config_entry.add_to_hass(hass) + + def receive_message(message_string: str) -> None: + """Receive a message with the transport. + + The message_string parameter is a string in the MySensors message format. + """ + gateway = transport.call_args[0][0] + # node_id;child_id;command;ack;type;payload\n + gateway.logic(message_string) + with patch("homeassistant.components.mysensors.device.UPDATE_DELAY", new=0): await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - yield config_entry + yield config_entry, receive_message def load_nodes_state(fixture_path: str) -> dict: @@ -151,7 +162,7 @@ def gps_sensor_state_fixture() -> dict: @pytest.fixture -def gps_sensor(gateway_nodes, gps_sensor_state) -> Sensor: +def gps_sensor(gateway_nodes: dict[int, Sensor], gps_sensor_state: dict) -> Sensor: """Load the gps sensor.""" nodes = update_gateway_nodes(gateway_nodes, gps_sensor_state) node = nodes[1] @@ -165,8 +176,70 @@ def power_sensor_state_fixture() -> dict: @pytest.fixture -def power_sensor(gateway_nodes, power_sensor_state) -> Sensor: +def power_sensor(gateway_nodes: dict[int, Sensor], power_sensor_state: dict) -> Sensor: """Load the power sensor.""" nodes = update_gateway_nodes(gateway_nodes, power_sensor_state) node = nodes[1] return node + + +@pytest.fixture(name="energy_sensor_state", scope="session") +def energy_sensor_state_fixture() -> dict: + """Load the energy sensor state.""" + return load_nodes_state("mysensors/energy_sensor_state.json") + + +@pytest.fixture +def energy_sensor( + gateway_nodes: dict[int, Sensor], energy_sensor_state: dict +) -> Sensor: + """Load the energy sensor.""" + nodes = update_gateway_nodes(gateway_nodes, energy_sensor_state) + node = nodes[1] + return node + + +@pytest.fixture(name="sound_sensor_state", scope="session") +def sound_sensor_state_fixture() -> dict: + """Load the sound sensor state.""" + return load_nodes_state("mysensors/sound_sensor_state.json") + + +@pytest.fixture +def sound_sensor(gateway_nodes: dict[int, Sensor], sound_sensor_state: dict) -> Sensor: + """Load the sound sensor.""" + nodes = update_gateway_nodes(gateway_nodes, sound_sensor_state) + node = nodes[1] + return node + + +@pytest.fixture(name="distance_sensor_state", scope="session") +def distance_sensor_state_fixture() -> dict: + """Load the distance sensor state.""" + return load_nodes_state("mysensors/distance_sensor_state.json") + + +@pytest.fixture +def distance_sensor( + gateway_nodes: dict[int, Sensor], distance_sensor_state: dict +) -> Sensor: + """Load the distance sensor.""" + nodes = update_gateway_nodes(gateway_nodes, distance_sensor_state) + node = nodes[1] + return node + + +@pytest.fixture(name="temperature_sensor_state", scope="session") +def temperature_sensor_state_fixture() -> dict: + """Load the temperature sensor state.""" + return load_nodes_state("mysensors/temperature_sensor_state.json") + + +@pytest.fixture +def temperature_sensor( + gateway_nodes: dict[int, Sensor], temperature_sensor_state: dict +) -> Sensor: + """Load the temperature sensor.""" + nodes = update_gateway_nodes(gateway_nodes, temperature_sensor_state) + node = nodes[1] + return node diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 6edddc68592..d648aebdefd 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -1,31 +1,158 @@ """Provide tests for mysensors sensor platform.""" +from __future__ import annotations +from typing import Callable -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from mysensors.sensor import Sensor +import pytest + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, POWER_WATT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem + +from tests.common import MockConfigEntry -async def test_gps_sensor(hass, gps_sensor, integration): +async def test_gps_sensor( + hass: HomeAssistant, + gps_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: """Test a gps sensor.""" entity_id = "sensor.gps_sensor_1_1" + _, receive_message = integration state = hass.states.get(entity_id) + assert state assert state.state == "40.741894,-73.989311,12" + altitude = 0 + new_coords = "40.782,-73.965" + message_string = f"1;1;1;0;49;{new_coords},{altitude}\n" -async def test_power_sensor(hass, power_sensor, integration): + receive_message(message_string) + # the integration adds multiple jobs to do the update currently + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == f"{new_coords},{altitude}" + + +async def test_power_sensor( + hass: HomeAssistant, + power_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: """Test a power sensor.""" entity_id = "sensor.power_sensor_1_1" state = hass.states.get(entity_id) + assert state assert state.state == "1200" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + + +async def test_energy_sensor( + hass: HomeAssistant, + energy_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: + """Test an energy sensor.""" + entity_id = "sensor.energy_sensor_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "18000" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + + +async def test_sound_sensor( + hass: HomeAssistant, + sound_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: + """Test a sound sensor.""" + entity_id = "sensor.sound_sensor_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "10" + assert state.attributes[ATTR_ICON] == "mdi:volume-high" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "dB" + + +async def test_distance_sensor( + hass: HomeAssistant, + distance_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], +) -> None: + """Test a distance sensor.""" + entity_id = "sensor.distance_sensor_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "15" + assert state.attributes[ATTR_ICON] == "mdi:ruler" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "cm" + + +@pytest.mark.parametrize( + "unit_system, unit", + [(METRIC_SYSTEM, TEMP_CELSIUS), (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT)], +) +async def test_temperature_sensor( + hass: HomeAssistant, + temperature_sensor: Sensor, + integration: tuple[MockConfigEntry, Callable[[str], None]], + unit_system: UnitSystem, + unit: str, +) -> None: + """Test a temperature sensor.""" + entity_id = "sensor.temperature_sensor_1_1" + hass.config.units = unit_system + _, receive_message = integration + temperature = "22.0" + message_string = f"1;1;1;0;0;{temperature}\n" + + receive_message(message_string) + # the integration adds multiple jobs to do the update currently + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == temperature + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == unit + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index c5850ce719d..ce9a221007a 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -19,6 +19,9 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, @@ -212,12 +215,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sds011_particulate_matter_10") assert state assert state.state == "19" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get( "sensor.nettigo_air_monitor_sds011_particulate_matter_10" @@ -228,12 +231,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sds011_particulate_matter_2_5") assert state assert state.state == "11" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get( "sensor.nettigo_air_monitor_sds011_particulate_matter_2_5" @@ -244,12 +247,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_1_0") assert state assert state.state == "31" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM1 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get( "sensor.nettigo_air_monitor_sps30_particulate_matter_1_0" @@ -260,12 +263,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_10") assert state assert state.state == "21" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get("sensor.nettigo_air_monitor_sps30_particulate_matter_10") assert entry @@ -274,12 +277,12 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_2_5") assert state assert state.state == "34" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" entry = registry.async_get( "sensor.nettigo_air_monitor_sps30_particulate_matter_2_5" @@ -295,7 +298,7 @@ async def test_sensor(hass): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_ICON) == "mdi:molecule" entry = registry.async_get( "sensor.nettigo_air_monitor_sps30_particulate_matter_4_0" diff --git a/tests/components/nanoleaf/__init__.py b/tests/components/nanoleaf/__init__.py new file mode 100644 index 00000000000..ee614fad173 --- /dev/null +++ b/tests/components/nanoleaf/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nanoleaf integration.""" diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py new file mode 100644 index 00000000000..93db43e40c9 --- /dev/null +++ b/tests/components/nanoleaf/test_config_flow.py @@ -0,0 +1,472 @@ +"""Test the Nanoleaf config flow.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from pynanoleaf import InvalidToken, NotAuthorizingNewTokens, Unavailable +from pynanoleaf.pynanoleaf import NanoleafError +import pytest + +from homeassistant import config_entries +from homeassistant.components.nanoleaf.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_NAME = "Canvas ADF9" +TEST_HOST = "192.168.0.100" +TEST_OTHER_HOST = "192.168.0.200" +TEST_TOKEN = "R34F1c92FNv3pcZs4di17RxGqiLSwHM" +TEST_OTHER_TOKEN = "Qs4dxGcHR34l29RF1c92FgiLQBt3pcM" +TEST_DEVICE_ID = "5E:2E:EA:XX:XX:XX" +TEST_OTHER_DEVICE_ID = "5E:2E:EA:YY:YY:YY" + + +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( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Unavailable("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + assert not result2["last_step"] + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["step_id"] == "link" + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Unavailable("message"), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result3["type"] == "abort" + assert result3["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + "error, reason", + [ + (Unavailable("message"), "cannot_connect"), + (InvalidToken("message"), "invalid_token"), + (Exception, "unknown"), + ], +) +async def test_user_error_setup_finish( + hass: HomeAssistant, error: Exception, reason: str +) -> None: + """Test abort flow if on error in setup_finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["step_id"] == "link" + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ), patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + side_effect=error, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result3["type"] == "abort" + assert result3["reason"] == reason + + +async def test_user_not_authorizing_new_tokens_user_step_link_step( + hass: HomeAssistant, +) -> None: + """Test we handle NotAuthorizingNewTokens in user step and link step.""" + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + ) 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} + ) + assert result["type"] == "form" + assert result["errors"] is None + assert result["step_id"] == "user" + assert not result["last_step"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["errors"] is None + assert result2["step_id"] == "link" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + assert result3["type"] == "form" + assert result3["errors"] is None + assert result3["step_id"] == "link" + + result4 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result4["type"] == "form" + assert result4["errors"] == {"base": "not_allowing_new_tokens"} + assert result4["step_id"] == "link" + + nanoleaf.authorize.side_effect = None + nanoleaf.authorize.return_value = None + + result5 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result5["type"] == "create_entry" + assert result5["title"] == TEST_NAME + assert result5["data"] == { + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_exception_user_step(hass: HomeAssistant) -> None: + """Test we handle Exception errors in user step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result2["type"] == "form" + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + assert not result2["last_step"] + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + }, + ) + assert result3["step_id"] == "link" + + 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"} + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + return_value=None, + ), patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + side_effect=Exception, + ): + result5 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result5["type"] == "abort" + assert result5["reason"] == "unknown" + + +@pytest.mark.parametrize( + "source, type_in_discovery_info", + [ + (config_entries.SOURCE_HOMEKIT, "_hap._tcp.local"), + (config_entries.SOURCE_ZEROCONF, "_nanoleafms._tcp.local"), + (config_entries.SOURCE_ZEROCONF, "_nanoleafapi._tcp.local."), + ], +) +async def test_discovery_link_unavailable( + hass: HomeAssistant, source: type, type_in_discovery_info: str +) -> None: + """Test discovery and abort if device is unavailable.""" + with patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + return_value={"name": TEST_NAME}, + ), patch( + "homeassistant.components.nanoleaf.config_flow.load_json", + return_value={}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data={ + "host": TEST_HOST, + "name": f"{TEST_NAME}.{type_in_discovery_info}", + "type": type_in_discovery_info, + "properties": {"id": TEST_DEVICE_ID}, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "link" + + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert context["title_placeholders"] == {"name": TEST_NAME} + assert context["unique_id"] == TEST_NAME + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf.authorize", + side_effect=Unavailable("message"), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +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, + data={CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_OTHER_TOKEN}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=nanoleaf, + ), patch( + "homeassistant.components.nanoleaf.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + assert result["type"] == "form" + assert result["step_id"] == "link" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + assert entry.data[CONF_HOST] == TEST_HOST + assert entry.data[CONF_TOKEN] == TEST_TOKEN + + +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}, + ), patch( + "homeassistant.components.nanoleaf.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={CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "error, reason", + [ + (Unavailable("message"), "cannot_connect"), + (InvalidToken("message"), "invalid_token"), + (Exception, "unknown"), + ], +) +async def test_import_config_error( + hass: HomeAssistant, error: NanoleafError, reason: str +) -> None: + """Test configuration import with errors in setup_finish.""" + with patch( + "homeassistant.components.nanoleaf.config_flow.pynanoleaf_get_info", + side_effect=error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + assert result["type"] == "abort" + assert result["reason"] == reason + + +@pytest.mark.parametrize( + "source, type_in_discovery", + [ + (config_entries.SOURCE_HOMEKIT, "_hap._tcp.local"), + (config_entries.SOURCE_ZEROCONF, "_nanoleafms._tcp.local"), + (config_entries.SOURCE_ZEROCONF, "_nanoleafapi._tcp.local"), + ], +) +@pytest.mark.parametrize( + "nanoleaf_conf_file, remove_config", + [ + ({TEST_DEVICE_ID: {"token": TEST_TOKEN}}, True), + ({TEST_HOST: {"token": TEST_TOKEN}}, True), + ( + { + TEST_DEVICE_ID: {"token": TEST_TOKEN}, + TEST_HOST: {"token": TEST_OTHER_TOKEN}, + }, + True, + ), + ( + { + TEST_DEVICE_ID: {"token": TEST_TOKEN}, + TEST_OTHER_HOST: {"token": TEST_OTHER_TOKEN}, + }, + False, + ), + ( + { + TEST_OTHER_DEVICE_ID: {"token": TEST_OTHER_TOKEN}, + TEST_HOST: {"token": TEST_TOKEN}, + }, + False, + ), + ], +) +async def test_import_discovery_integration( + hass: HomeAssistant, + source: str, + type_in_discovery: str, + nanoleaf_conf_file: dict[str, dict[str, str]], + remove_config: bool, +) -> None: + """ + Test discovery integration import. + + Test with different discovery flow sources and corresponding types. + Test with different .nanoleaf_conf files with device_id (>= 2021.4), host (< 2021.4) and combination. + Test removing the .nanoleaf_conf file if it was the only device in the file. + Test updating the .nanoleaf_conf file if it was not the only device in the file. + """ + with patch( + "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}, + ), patch( + "homeassistant.components.nanoleaf.config_flow.save_json", + return_value=None, + ) as mock_save_json, patch( + "homeassistant.components.nanoleaf.config_flow.os.remove", + return_value=None, + ) as mock_remove, patch( + "homeassistant.components.nanoleaf.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data={ + "host": TEST_HOST, + "name": f"{TEST_NAME}.{type_in_discovery}", + "type": type_in_discovery, + "properties": {"id": TEST_DEVICE_ID}, + }, + ) + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + } + + if remove_config: + mock_save_json.assert_not_called() + mock_remove.assert_called_once() + else: + mock_save_json.assert_called_once() + mock_remove.assert_not_called() + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nest/device_info_test.py b/tests/components/nest/device_info_test.py index a0c6973c1d6..90b70f61d15 100644 --- a/tests/components/nest/device_info_test.py +++ b/tests/components/nest/device_info_test.py @@ -93,11 +93,11 @@ def test_device_invalid_type(): device_info = NestDeviceInfo(device) assert device_info.device_name == "My Doorbell" - assert device_info.device_model == "Unknown" + assert device_info.device_model is None assert device_info.device_brand == "Google Nest" assert device_info.device_info == { "identifiers": {("nest", "some-device-id")}, "name": "My Doorbell", "manufacturer": "Google Nest", - "model": "Unknown", + "model": None, } diff --git a/tests/components/nest/sensor_sdm_test.py b/tests/components/nest/sensor_sdm_test.py index cc18e8cd3ae..dfdfd58d546 100644 --- a/tests/components/nest/sensor_sdm_test.py +++ b/tests/components/nest/sensor_sdm_test.py @@ -208,5 +208,5 @@ async def test_device_with_unknown_type(hass): device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My Sensor" - assert device.model == "Unknown" + assert device.model is None assert device.identifiers == {("nest", "some-device-id")} diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 8be010cc802..f0e7cde7359 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -19,10 +19,6 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a select_entity = "select.netatmo_myhome" assert hass.states.get(select_entity).state == "Default" - assert hass.states.get(select_entity).attributes[ATTR_OPTIONS] == [ - "Default", - "Winter", - ] # Fake backend response changing schedule response = { @@ -32,8 +28,13 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a "push_type": "home_event_changed", } await simulate_webhook(hass, webhook_id, response) + await hass.async_block_till_done() assert hass.states.get(select_entity).state == "Winter" + assert hass.states.get(select_entity).attributes[ATTR_OPTIONS] == [ + "Default", + "Winter", + ] # Test setting a different schedule with patch( diff --git a/tests/components/nmap_tracker/__init__.py b/tests/components/nmap_tracker/__init__.py new file mode 100644 index 00000000000..f5e0c85df31 --- /dev/null +++ b/tests/components/nmap_tracker/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nmap Tracker integration.""" diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py new file mode 100644 index 00000000000..74997df5a4f --- /dev/null +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -0,0 +1,309 @@ +"""Test the Nmap Tracker config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, +) +from homeassistant.components.nmap_tracker.const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DOMAIN, +) +from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS +from homeassistant.core import CoreState, HomeAssistant + +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: + """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"] == {} + + schema_defaults = result["data_schema"]({}) + assert CONF_SCAN_INTERVAL not in schema_defaults + + with patch( + "homeassistant.components.nmap_tracker.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: hosts, + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == f"Nmap Tracker {hosts}" + assert result2["data"] == {} + assert result2["options"] == { + CONF_HOSTS: hosts, + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_range(hass: HomeAssistant) -> 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( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.nmap_tracker.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: "192.168.0.5-12", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Nmap Tracker 192.168.0.5-12" + assert result2["data"] == {} + assert result2["options"] == { + CONF_HOSTS: "192.168.0.5-12", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_hosts(hass: HomeAssistant) -> None: + """Test invalid hosts passed in.""" + 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"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: "not an ip block", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_HOSTS: "invalid_hosts"} + + +async def test_form_already_configured(hass: HomeAssistant) -> None: + """Test duplicate host list.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_HOSTS: "192.168.0.0/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: "192.168.0.0/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + +async def test_form_invalid_excludes(hass: HomeAssistant) -> None: + """Test invalid excludes passed in.""" + 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"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS: "3.3.3.3", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "not an exclude", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"} + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test we can edit options.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_HOSTS: "192.168.1.0/24", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + config_entry.add_to_hass(hass) + hass.state = CoreState.stopped + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + assert result["data_schema"]({}) == { + CONF_EXCLUDE: "4.4.4.4", + CONF_HOME_INTERVAL: 3, + CONF_HOSTS: "192.168.1.0/24", + CONF_CONSIDER_HOME: 180, + CONF_SCAN_INTERVAL: 120, + CONF_OPTIONS: "-F -T4 --min-rate 10 --host-timeout 5s", + } + + with patch( + "homeassistant.components.nmap_tracker.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24", + CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, + CONF_OPTIONS: "-sn", + CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", + CONF_SCAN_INTERVAL: 10, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", + CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, + CONF_OPTIONS: "-sn", + CONF_EXCLUDE: "4.4.4.4,5.5.5.5", + CONF_SCAN_INTERVAL: 10, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass: HomeAssistant) -> None: + """Test we can import from yaml.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.nmap_tracker.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={ + CONF_HOSTS: "1.2.3.4/20", + CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", + CONF_SCAN_INTERVAL: 2000, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "Nmap Tracker 1.2.3.4/20" + assert result["data"] == {} + assert result["options"] == { + CONF_HOSTS: "1.2.3.4/20", + CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4,6.4.3.2", + CONF_SCAN_INTERVAL: 2000, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_aborts_if_matching(hass: HomeAssistant) -> None: + """Test we can import from yaml.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_HOSTS: "192.168.0.0/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4", + }, + ) + config_entry.add_to_hass(hass) + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOSTS: "192.168.0.0/20", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index f1154581fdc..8fdf03a7d7b 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -1,7 +1,18 @@ """The tests for the Number component.""" from unittest.mock import MagicMock -from homeassistant.components.number import NumberEntity +import pytest + +from homeassistant.components.number import ( + ATTR_STEP, + ATTR_VALUE, + DOMAIN, + SERVICE_SET_VALUE, + NumberEntity, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component class MockDefaultNumberEntity(NumberEntity): @@ -27,7 +38,7 @@ class MockNumberEntity(NumberEntity): return 0.5 -async def test_step(hass): +async def test_step(hass: HomeAssistant) -> None: """Test the step calculation.""" number = MockDefaultNumberEntity() assert number.step == 1.0 @@ -36,7 +47,7 @@ async def test_step(hass): assert number_2.step == 0.1 -async def test_sync_set_value(hass): +async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" number = MockDefaultNumberEntity() number.hass = hass @@ -46,3 +57,43 @@ async def test_sync_set_value(hass): assert number.set_value.called assert number.set_value.call_args[0][0] == 42 + + +async def test_custom_integration_and_validation( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test we can only set valid values.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "50.0" + assert state.attributes.get(ATTR_STEP) == 1.0 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 60.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + hass.states.async_set("number.test", 60.0) + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "60.0" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "60.0" diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 1712a5500dd..0ca9b55c41a 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -30,6 +30,9 @@ async def setup_onewire_sysbus_integration(hass): data={ CONF_TYPE: CONF_TYPE_SYSBUS, CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, + "names": { + "10-111111111111": "My DS18B20", + }, }, unique_id=f"{CONF_TYPE_SYSBUS}:{DEFAULT_SYSBUS_MOUNT_DIR}", options={}, diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 5c12571fc1e..9c37442e2f7 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -5,9 +5,20 @@ from pyownet.protocol import Error as ProtocolError from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.onewire.const import DOMAIN, PRESSURE_CBAR -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, DEVICE_CLASS_CURRENT, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -24,6 +35,8 @@ from homeassistant.const import ( TEMP_CELSIUS, ) +MANUFACTURER = "Maxim Integrated" + MOCK_OWPROXY_DEVICES = { "00.111111111111": { "inject_reads": [ @@ -36,10 +49,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2405", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "05.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2405", - "name": "05.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "05.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2405", + ATTR_NAME: "05.111111111111", }, SWITCH_DOMAIN: [ { @@ -47,8 +60,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/05.111111111111/PIO", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -58,10 +71,10 @@ MOCK_OWPROXY_DEVICES = { b"DS18S20", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "10.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS18S20", - "name": "10.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "10.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS18S20", + ATTR_NAME: "10.111111111111", }, SENSOR_DOMAIN: [ { @@ -69,8 +82,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/10.111111111111/temperature", "injected_value": b" 25.123", "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -79,10 +93,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2406", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "12.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2406", - "name": "12.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "12.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2406", + ATTR_NAME: "12.111111111111", }, BINARY_SENSOR_DOMAIN: [ { @@ -90,8 +104,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/sensed.A", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -99,8 +113,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/sensed.B", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -110,18 +124,20 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/TAI8570/temperature", "injected_value": b" 25.123", "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.12_111111111111_pressure", "unique_id": "/12.111111111111/TAI8570/pressure", "injected_value": b" 1025.123", "result": "1025.1", - "unit": PRESSURE_MBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], SWITCH_DOMAIN: [ @@ -130,8 +146,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/PIO.A", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -139,8 +155,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/PIO.B", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -148,8 +164,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/latch.A", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -157,8 +173,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/12.111111111111/latch.B", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -168,10 +184,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2423", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "1D.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2423", - "name": "1D.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "1D.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2423", + ATTR_NAME: "1D.111111111111", }, SENSOR_DOMAIN: [ { @@ -179,16 +195,18 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/1D.111111111111/counter.A", "injected_value": b" 251123", "result": "251123", - "unit": "count", - "class": None, + ATTR_UNIT_OF_MEASUREMENT: "count", + ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, { "entity_id": "sensor.1d_111111111111_counter_b", "unique_id": "/1D.111111111111/counter.B", "injected_value": b" 248125", "result": "248125", - "unit": "count", - "class": None, + ATTR_UNIT_OF_MEASUREMENT: "count", + ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, ], }, @@ -197,10 +215,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2409", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "1F.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2409", - "name": "1F.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "1F.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2409", + ATTR_NAME: "1F.111111111111", }, "branches": { "aux": {}, @@ -210,10 +228,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2423", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "1D.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2423", - "name": "1D.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "1D.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2423", + ATTR_NAME: "1D.111111111111", }, SENSOR_DOMAIN: [ { @@ -222,8 +240,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/1D.111111111111/counter.A", "injected_value": b" 251123", "result": "251123", - "unit": "count", - "class": None, + ATTR_UNIT_OF_MEASUREMENT: "count", + ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, { "entity_id": "sensor.1d_111111111111_counter_b", @@ -231,8 +250,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/1D.111111111111/counter.B", "injected_value": b" 248125", "result": "248125", - "unit": "count", - "class": None, + ATTR_UNIT_OF_MEASUREMENT: "count", + ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, }, ], }, @@ -244,10 +264,10 @@ MOCK_OWPROXY_DEVICES = { b"DS1822", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "22.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS1822", - "name": "22.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "22.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS1822", + ATTR_NAME: "22.111111111111", }, SENSOR_DOMAIN: [ { @@ -255,8 +275,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/22.111111111111/temperature", "injected_value": ProtocolError, "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -265,10 +286,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2438", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "26.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2438", - "name": "26.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "26.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2438", + ATTR_NAME: "26.111111111111", }, SENSOR_DOMAIN: [ { @@ -276,98 +297,109 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/26.111111111111/temperature", "injected_value": b" 25.123", "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity", "unique_id": "/26.111111111111/humidity", "injected_value": b" 72.7563", "result": "72.8", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity_hih3600", "unique_id": "/26.111111111111/HIH3600/humidity", "injected_value": b" 73.7563", "result": "73.8", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity_hih4000", "unique_id": "/26.111111111111/HIH4000/humidity", "injected_value": b" 74.7563", "result": "74.8", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity_hih5030", "unique_id": "/26.111111111111/HIH5030/humidity", "injected_value": b" 75.7563", "result": "75.8", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_humidity_htm1735", "unique_id": "/26.111111111111/HTM1735/humidity", "injected_value": ProtocolError, "result": "unknown", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_pressure", "unique_id": "/26.111111111111/B1-R1-A/pressure", "injected_value": b" 969.265", "result": "969.3", - "unit": PRESSURE_MBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_illuminance", "unique_id": "/26.111111111111/S3-R1-A/illuminance", "injected_value": b" 65.8839", "result": "65.9", - "unit": LIGHT_LUX, - "class": DEVICE_CLASS_ILLUMINANCE, + ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_voltage_vad", "unique_id": "/26.111111111111/VAD", "injected_value": b" 2.97", "result": "3.0", - "unit": ELECTRIC_POTENTIAL_VOLT, - "class": DEVICE_CLASS_VOLTAGE, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_voltage_vdd", "unique_id": "/26.111111111111/VDD", "injected_value": b" 4.74", "result": "4.7", - "unit": ELECTRIC_POTENTIAL_VOLT, - "class": DEVICE_CLASS_VOLTAGE, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.26_111111111111_current", "unique_id": "/26.111111111111/IAD", "injected_value": b" 1", "result": "1.0", - "unit": ELECTRIC_CURRENT_AMPERE, - "class": DEVICE_CLASS_CURRENT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, "disabled": True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -376,10 +408,10 @@ MOCK_OWPROXY_DEVICES = { b"DS18B20", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "28.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS18B20", - "name": "28.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "28.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS18B20", + ATTR_NAME: "28.111111111111", }, SENSOR_DOMAIN: [ { @@ -387,8 +419,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/28.111111111111/temperature", "injected_value": b" 26.984", "result": "27.0", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -397,10 +430,10 @@ MOCK_OWPROXY_DEVICES = { b"DS2408", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "29.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS2408", - "name": "29.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "29.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS2408", + ATTR_NAME: "29.111111111111", }, BINARY_SENSOR_DOMAIN: [ { @@ -408,8 +441,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.0", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -417,8 +450,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.1", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -426,8 +459,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.2", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -435,8 +468,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.3", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -444,8 +477,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.4", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -453,8 +486,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.5", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -462,8 +495,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.6", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -471,8 +504,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/sensed.7", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -482,8 +515,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.0", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -491,8 +524,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.1", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -500,8 +533,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.2", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -509,8 +542,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.3", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -518,8 +551,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.4", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -527,8 +560,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.5", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -536,8 +569,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.6", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -545,8 +578,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/PIO.7", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -554,8 +587,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.0", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -563,8 +596,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.1", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -572,8 +605,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.2", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -581,8 +614,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.3", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -590,8 +623,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.4", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -599,8 +632,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.5", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -608,8 +641,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.6", "injected_value": b" 1", "result": STATE_ON, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, { @@ -617,8 +650,8 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/29.111111111111/latch.7", "injected_value": b" 0", "result": STATE_OFF, - "unit": None, - "class": None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, "disabled": True, }, ], @@ -628,10 +661,10 @@ MOCK_OWPROXY_DEVICES = { b"DS1825", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "3B.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS1825", - "name": "3B.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "3B.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS1825", + ATTR_NAME: "3B.111111111111", }, SENSOR_DOMAIN: [ { @@ -639,8 +672,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/3B.111111111111/temperature", "injected_value": b" 28.243", "result": "28.2", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -649,10 +683,10 @@ MOCK_OWPROXY_DEVICES = { b"DS28EA00", # read device type ], "device_info": { - "identifiers": {(DOMAIN, "42.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "DS28EA00", - "name": "42.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "42.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "DS28EA00", + ATTR_NAME: "42.111111111111", }, SENSOR_DOMAIN: [ { @@ -660,8 +694,9 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/42.111111111111/temperature", "injected_value": b" 29.123", "result": "29.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -670,10 +705,10 @@ MOCK_OWPROXY_DEVICES = { b"HobbyBoards_EF", # read type ], "device_info": { - "identifiers": {(DOMAIN, "EF.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "HobbyBoards_EF", - "name": "EF.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "HobbyBoards_EF", + ATTR_NAME: "EF.111111111111", }, SENSOR_DOMAIN: [ { @@ -681,24 +716,27 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/EF.111111111111/humidity/humidity_corrected", "injected_value": b" 67.745", "result": "67.7", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111111_humidity_raw", "unique_id": "/EF.111111111111/humidity/humidity_raw", "injected_value": b" 65.541", "result": "65.5", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111111_temperature", "unique_id": "/EF.111111111111/humidity/temperature", "injected_value": b" 25.123", "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -711,10 +749,10 @@ MOCK_OWPROXY_DEVICES = { b" 0", # read is_leaf_3 ], "device_info": { - "identifiers": {(DOMAIN, "EF.111111111112")}, - "manufacturer": "Maxim Integrated", - "model": "HB_MOISTURE_METER", - "name": "EF.111111111112", + ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111112")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "HB_MOISTURE_METER", + ATTR_NAME: "EF.111111111112", }, SENSOR_DOMAIN: [ { @@ -722,32 +760,36 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/EF.111111111112/moisture/sensor.0", "injected_value": b" 41.745", "result": "41.7", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111112_wetness_1", "unique_id": "/EF.111111111112/moisture/sensor.1", "injected_value": b" 42.541", "result": "42.5", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111112_moisture_2", "unique_id": "/EF.111111111112/moisture/sensor.2", "injected_value": b" 43.123", "result": "43.1", - "unit": PRESSURE_CBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_CBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.ef_111111111112_moisture_3", "unique_id": "/EF.111111111112/moisture/sensor.3", "injected_value": b" 44.123", "result": "44.1", - "unit": PRESSURE_CBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_CBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -757,10 +799,10 @@ MOCK_OWPROXY_DEVICES = { b"EDS0068", # read device_type - note EDS specific ], "device_info": { - "identifiers": {(DOMAIN, "7E.111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "EDS", - "name": "7E.111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "7E.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "EDS", + ATTR_NAME: "7E.111111111111", }, SENSOR_DOMAIN: [ { @@ -768,32 +810,36 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/7E.111111111111/EDS0068/temperature", "injected_value": b" 13.9375", "result": "13.9", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.7e_111111111111_pressure", "unique_id": "/7E.111111111111/EDS0068/pressure", "injected_value": b" 1012.21", "result": "1012.2", - "unit": PRESSURE_MBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.7e_111111111111_illuminance", "unique_id": "/7E.111111111111/EDS0068/light", "injected_value": b" 65.8839", "result": "65.9", - "unit": LIGHT_LUX, - "class": DEVICE_CLASS_ILLUMINANCE, + ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.7e_111111111111_humidity", "unique_id": "/7E.111111111111/EDS0068/humidity", "injected_value": b" 41.375", "result": "41.4", - "unit": PERCENTAGE, - "class": DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -803,10 +849,10 @@ MOCK_OWPROXY_DEVICES = { b"EDS0066", # read device_type - note EDS specific ], "device_info": { - "identifiers": {(DOMAIN, "7E.222222222222")}, - "manufacturer": "Maxim Integrated", - "model": "EDS", - "name": "7E.222222222222", + ATTR_IDENTIFIERS: {(DOMAIN, "7E.222222222222")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "EDS", + ATTR_NAME: "7E.222222222222", }, SENSOR_DOMAIN: [ { @@ -814,16 +860,18 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/7E.222222222222/EDS0066/temperature", "injected_value": b" 13.9375", "result": "13.9", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, { "entity_id": "sensor.7e_222222222222_pressure", "unique_id": "/7E.222222222222/EDS0066/pressure", "injected_value": b" 1012.21", "result": "1012.2", - "unit": PRESSURE_MBAR, - "class": DEVICE_CLASS_PRESSURE, + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_MBAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -833,10 +881,10 @@ MOCK_SYSBUS_DEVICES = { "00-111111111111": {SENSOR_DOMAIN: []}, "10-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "10-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "10", - "name": "10-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "10-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "10", + ATTR_NAME: "10-111111111111", }, SENSOR_DOMAIN: [ { @@ -844,8 +892,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/10-111111111111/w1_slave", "injected_value": 25.123, "result": "25.1", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, @@ -853,10 +902,10 @@ MOCK_SYSBUS_DEVICES = { "1D-111111111111": {SENSOR_DOMAIN: []}, "22-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "22-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "22", - "name": "22-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "22-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "22", + ATTR_NAME: "22-111111111111", }, "sensor": [ { @@ -864,18 +913,19 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/22-111111111111/w1_slave", "injected_value": FileNotFoundError, "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, "26-111111111111": {SENSOR_DOMAIN: []}, "28-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "28-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "28", - "name": "28-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "28-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "28", + ATTR_NAME: "28-111111111111", }, SENSOR_DOMAIN: [ { @@ -883,18 +933,19 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/28-111111111111/w1_slave", "injected_value": InvalidCRCException, "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, "29-111111111111": {SENSOR_DOMAIN: []}, "3B-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "3B-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "3B", - "name": "3B-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "3B-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "3B", + ATTR_NAME: "3B-111111111111", }, SENSOR_DOMAIN: [ { @@ -902,17 +953,18 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/3B-111111111111/w1_slave", "injected_value": 29.993, "result": "30.0", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, "42-111111111111": { "device_info": { - "identifiers": {(DOMAIN, "42-111111111111")}, - "manufacturer": "Maxim Integrated", - "model": "42", - "name": "42-111111111111", + ATTR_IDENTIFIERS: {(DOMAIN, "42-111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "42", + ATTR_NAME: "42-111111111111", }, SENSOR_DOMAIN: [ { @@ -920,17 +972,18 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/42-111111111111/w1_slave", "injected_value": UnsupportResponseException, "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, "42-111111111112": { "device_info": { - "identifiers": {(DOMAIN, "42-111111111112")}, - "manufacturer": "Maxim Integrated", - "model": "42", - "name": "42-111111111112", + ATTR_IDENTIFIERS: {(DOMAIN, "42-111111111112")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "42", + ATTR_NAME: "42-111111111112", }, SENSOR_DOMAIN: [ { @@ -938,17 +991,18 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/42-111111111112/w1_slave", "injected_value": [UnsupportResponseException] * 9 + [27.993], "result": "28.0", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, "42-111111111113": { "device_info": { - "identifiers": {(DOMAIN, "42-111111111113")}, - "manufacturer": "Maxim Integrated", - "model": "42", - "name": "42-111111111113", + ATTR_IDENTIFIERS: {(DOMAIN, "42-111111111113")}, + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: "42", + ATTR_NAME: "42-111111111113", }, SENSOR_DOMAIN: [ { @@ -956,8 +1010,9 @@ MOCK_SYSBUS_DEVICES = { "unique_id": "/sys/bus/w1/devices/42-111111111113/w1_slave", "injected_value": [UnsupportResponseException] * 10 + [27.993], "result": "unknown", - "unit": TEMP_CELSIUS, - "class": DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, ], }, diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 91ae472278a..c82bc88c3a6 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -38,7 +38,7 @@ async def test_owserver_binary_sensor(owproxy, hass, device_id): # Force enable binary sensors patch_device_binary_sensors = copy.deepcopy(DEVICE_BINARY_SENSORS) for item in patch_device_binary_sensors[device_id[0:2]]: - item["default_disabled"] = False + item.entity_registry_enabled_default = True with patch( "homeassistant.components.onewire.PLATFORMS", [BINARY_SENSOR_DOMAIN] diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 66025770f41..d83e9203270 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -7,11 +7,10 @@ from homeassistant.components.onewire.const import ( CONF_MOUNT_DIR, CONF_TYPE_OWSERVER, CONF_TYPE_SYSBUS, - DEFAULT_OWSERVER_PORT, DEFAULT_SYSBUS_MOUNT_DIR, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -201,135 +200,3 @@ async def test_user_sysbus_duplicate(hass): assert result["reason"] == "already_configured" await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_sysbus(hass): - """Test import step.""" - - with patch( - "homeassistant.components.onewire.onewirehub.os.path.isdir", - return_value=True, - ), patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_TYPE: CONF_TYPE_SYSBUS}, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_SYSBUS_MOUNT_DIR - assert result["data"] == { - CONF_TYPE: CONF_TYPE_SYSBUS, - CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_sysbus_with_mount_dir(hass): - """Test import step.""" - - with patch( - "homeassistant.components.onewire.onewirehub.os.path.isdir", - return_value=True, - ), patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TYPE: CONF_TYPE_SYSBUS, - CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, - }, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_SYSBUS_MOUNT_DIR - assert result["data"] == { - CONF_TYPE: CONF_TYPE_SYSBUS, - CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_owserver(hass): - """Test import step.""" - - with patch("homeassistant.components.onewire.onewirehub.protocol.proxy",), patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - }, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "1.2.3.4" - assert result["data"] == { - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - CONF_PORT: DEFAULT_OWSERVER_PORT, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_owserver_with_port(hass): - """Test import step.""" - - with patch("homeassistant.components.onewire.onewirehub.protocol.proxy",), patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - }, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "1.2.3.4" - assert result["data"] == { - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_owserver_duplicate(hass): - """Test OWServer flow.""" - # Initialise with single entry - with patch( - "homeassistant.components.onewire.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - await setup_onewire_owserver_integration(hass) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - # Import duplicate entry - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TYPE: CONF_TYPE_OWSERVER, - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - }, - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index f3063dfc128..eacaa148b45 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -9,11 +9,19 @@ from homeassistant.components.onewire.const import ( DOMAIN, PLATFORMS, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, +) from homeassistant.setup import async_setup_component from . import ( setup_onewire_patched_owserver_integration, + setup_onewire_sysbus_integration, setup_owproxy_mock_devices, setup_sysbus_mock_devices, ) @@ -25,16 +33,6 @@ MOCK_COUPLERS = { key: value for (key, value) in MOCK_OWPROXY_DEVICES.items() if "branches" in value } -MOCK_SYSBUS_CONFIG = { - SENSOR_DOMAIN: { - "platform": DOMAIN, - "mount_dir": DEFAULT_SYSBUS_MOUNT_DIR, - "names": { - "10-111111111111": "My DS18B20", - }, - } -} - async def test_setup_minimum(hass): """Test old platform setup with minimum configuration.""" @@ -124,14 +122,11 @@ async def test_sensors_on_owserver_coupler(owproxy, hass, device_id): registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_sensor["unique_id"] - assert registry_entry.unit_of_measurement == expected_sensor["unit"] - assert registry_entry.device_class == expected_sensor["class"] assert registry_entry.disabled == expected_sensor.get("disabled", False) state = hass.states.get(entity_id) - if registry_entry.disabled: - assert state is None - else: - assert state.state == expected_sensor["result"] + assert state.state == expected_sensor["result"] + for attr in (ATTR_DEVICE_CLASS, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT): + assert state.attributes.get(attr) == expected_sensor[attr] assert state.attributes["device_file"] == expected_sensor["device_file"] @@ -164,23 +159,23 @@ async def test_owserver_setup_valid_device(owproxy, hass, device_id, platform): registry_entry = device_registry.async_get_device({(DOMAIN, device_id)}) assert registry_entry is not None assert registry_entry.identifiers == {(DOMAIN, device_id)} - assert registry_entry.manufacturer == device_info["manufacturer"] - assert registry_entry.name == device_info["name"] - assert registry_entry.model == device_info["model"] + assert registry_entry.manufacturer == device_info[ATTR_MANUFACTURER] + assert registry_entry.name == device_info[ATTR_NAME] + assert registry_entry.model == device_info[ATTR_MODEL] for expected_entity in expected_entities: entity_id = expected_entity["entity_id"] registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_entity["unique_id"] - assert registry_entry.unit_of_measurement == expected_entity["unit"] - assert registry_entry.device_class == expected_entity["class"] assert registry_entry.disabled == expected_entity.get("disabled", False) state = hass.states.get(entity_id) if registry_entry.disabled: assert state is None else: assert state.state == expected_entity["result"] + for attr in (ATTR_DEVICE_CLASS, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT): + assert state.attributes.get(attr) == expected_entity[attr] assert state.attributes["device_file"] == expected_entity.get( "device_file", registry_entry.unique_id ) @@ -200,13 +195,11 @@ async def test_onewiredirect_setup_valid_device(hass, device_id): mock_device = MOCK_SYSBUS_DEVICES[device_id] expected_entities = mock_device.get(SENSOR_DOMAIN, []) - with patch( - "homeassistant.components.onewire.onewirehub.os.path.isdir", return_value=True - ), patch("pi1wire._finder.glob.glob", return_value=glob_result,), patch( + with patch("pi1wire._finder.glob.glob", return_value=glob_result,), patch( "pi1wire.OneWire.get_temperature", side_effect=read_side_effect, ): - assert await async_setup_component(hass, SENSOR_DOMAIN, MOCK_SYSBUS_CONFIG) + assert await setup_onewire_sysbus_integration(hass) await hass.async_block_till_done() assert len(entity_registry.entities) == len(expected_entities) @@ -217,16 +210,16 @@ async def test_onewiredirect_setup_valid_device(hass, device_id): registry_entry = device_registry.async_get_device({(DOMAIN, device_id)}) assert registry_entry is not None assert registry_entry.identifiers == {(DOMAIN, device_id)} - assert registry_entry.manufacturer == device_info["manufacturer"] - assert registry_entry.name == device_info["name"] - assert registry_entry.model == device_info["model"] + assert registry_entry.manufacturer == device_info[ATTR_MANUFACTURER] + assert registry_entry.name == device_info[ATTR_NAME] + assert registry_entry.model == device_info[ATTR_MODEL] for expected_sensor in expected_entities: entity_id = expected_sensor["entity_id"] registry_entry = entity_registry.entities.get(entity_id) assert registry_entry is not None assert registry_entry.unique_id == expected_sensor["unique_id"] - assert registry_entry.unit_of_measurement == expected_sensor["unit"] - assert registry_entry.device_class == expected_sensor["class"] state = hass.states.get(entity_id) assert state.state == expected_sensor["result"] + for attr in (ATTR_DEVICE_CLASS, ATTR_STATE_CLASS, ATTR_UNIT_OF_MEASUREMENT): + assert state.attributes.get(attr) == expected_sensor[attr] diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 91a9e32e902..bfc4550cdc7 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -39,7 +39,7 @@ async def test_owserver_switch(owproxy, hass, device_id): # Force enable switches patch_device_switches = copy.deepcopy(DEVICE_SWITCHES) for item in patch_device_switches[device_id[0:2]]: - item["default_disabled"] = False + item.entity_registry_enabled_default = True with patch( "homeassistant.components.onewire.PLATFORMS", [SWITCH_DOMAIN] diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py index 3feeb2638b4..8afd9803d7c 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyopenuv.errors import InvalidApiKeyError from homeassistant import data_entry_flow -from homeassistant.components.openuv import DOMAIN +from homeassistant.components.openuv import CONF_FROM_WINDOW, CONF_TO_WINDOW, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, @@ -57,6 +57,37 @@ async def test_invalid_api_key(hass): assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} +async def test_options_flow(hass): + """Test config flow options.""" + conf = { + CONF_API_KEY: "12345abcde", + CONF_ELEVATION: 59.1234, + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + } + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="abcde12345", + data=conf, + ) + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.openuv.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(config_entry.entry_id) + 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_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 2.0} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 2.0} + + async def test_step_user(hass): """Test that the user step works.""" conf = { diff --git a/tests/components/p1_monitor/__init__.py b/tests/components/p1_monitor/__init__.py new file mode 100644 index 00000000000..53a063c5f5b --- /dev/null +++ b/tests/components/p1_monitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the P1 Monitor integration.""" diff --git a/tests/components/p1_monitor/conftest.py b/tests/components/p1_monitor/conftest.py new file mode 100644 index 00000000000..dbdf572c6de --- /dev/null +++ b/tests/components/p1_monitor/conftest.py @@ -0,0 +1,59 @@ +"""Fixtures for P1 Monitor integration tests.""" +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from p1monitor import Phases, Settings, SmartMeter +import pytest + +from homeassistant.components.p1_monitor.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="monitor", + domain=DOMAIN, + data={CONF_HOST: "example"}, + unique_id="unique_thingy", + ) + + +@pytest.fixture +def mock_p1monitor(): + """Return a mocked P1 Monitor client.""" + with patch("homeassistant.components.p1_monitor.P1Monitor") as p1monitor_mock: + client = p1monitor_mock.return_value + client.smartmeter = AsyncMock( + return_value=SmartMeter.from_dict( + json.loads(load_fixture("p1_monitor/smartmeter.json")) + ) + ) + client.phases = AsyncMock( + return_value=Phases.from_dict( + json.loads(load_fixture("p1_monitor/phases.json")) + ) + ) + client.settings = AsyncMock( + return_value=Settings.from_dict( + json.loads(load_fixture("p1_monitor/settings.json")) + ) + ) + yield p1monitor_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_p1monitor: MagicMock +) -> MockConfigEntry: + """Set up the P1 Monitor integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py new file mode 100644 index 00000000000..f6ce5fe5d9d --- /dev/null +++ b/tests/components/p1_monitor/test_config_flow.py @@ -0,0 +1,62 @@ +"""Test the P1 Monitor config flow.""" +from unittest.mock import patch + +from p1monitor import P1MonitorError + +from homeassistant.components.p1_monitor.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + with patch( + "homeassistant.components.p1_monitor.config_flow.P1Monitor.smartmeter" + ) as mock_p1monitor, patch( + "homeassistant.components.p1_monitor.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "Name", + CONF_HOST: "example.com", + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Name" + assert result2.get("data") == { + CONF_HOST: "example.com", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_p1monitor.mock_calls) == 1 + + +async def test_api_error(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.p1_monitor.P1Monitor.smartmeter", + side_effect=P1MonitorError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_NAME: "Name", + CONF_HOST: "example.com", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} diff --git a/tests/components/p1_monitor/test_init.py b/tests/components/p1_monitor/test_init.py new file mode 100644 index 00000000000..bddaff137e6 --- /dev/null +++ b/tests/components/p1_monitor/test_init.py @@ -0,0 +1,44 @@ +"""Tests for the P1 Monitor integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from p1monitor import P1MonitorConnectionError + +from homeassistant.components.p1_monitor.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_p1monitor: AsyncMock +) -> None: + """Test the P1 Monitor configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + + +@patch( + "homeassistant.components.p1_monitor.P1Monitor.request", + side_effect=P1MonitorConnectionError, +) +async def test_config_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the P1 Monitor configuration entry not ready.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py new file mode 100644 index 00000000000..90733ce8941 --- /dev/null +++ b/tests/components/p1_monitor/test_sensor.py @@ -0,0 +1,201 @@ +"""Tests for the sensors provided by the P1 Monitor integration.""" +import pytest + +from homeassistant.components.p1_monitor.const import DOMAIN, ENTRY_TYPE_SERVICE +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CURRENCY_EURO, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_smartmeter( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - SmartMeter sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_power_consumption") + entry = entity_registry.async_get("sensor.monitor_power_consumption") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_power_consumption" + assert state.state == "877" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumption" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_energy_consumption_high") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_high") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_energy_consumption_high" + assert state.state == "2770.133" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - High Tariff" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_energy_tariff_period") + entry = entity_registry.async_get("sensor.monitor_energy_tariff_period") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_energy_tariff_period" + assert state.state == "high" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Tariff Period" + assert state.attributes.get(ATTR_ICON) == "mdi:calendar-clock" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_smartmeter")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "SmartMeter" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +async def test_phases( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - Phases sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_voltage_phase_l1") + entry = entity_registry.async_get("sensor.monitor_voltage_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_voltage_phase_l1" + assert state.state == "233.6" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Voltage Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_POTENTIAL_VOLT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_VOLTAGE + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_current_phase_l1") + entry = entity_registry.async_get("sensor.monitor_current_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_current_phase_l1" + assert state.state == "1.6" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Current Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_AMPERE + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_power_consumed_phase_l1") + entry = entity_registry.async_get("sensor.monitor_power_consumed_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_power_consumed_phase_l1" + assert state.state == "315" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumed Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_phases")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "Phases" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +async def test_settings( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - Settings sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_energy_consumption_price_low") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_price_low") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_settings_energy_consumption_price_low" + assert state.state == "0.20522" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption Price - Low" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO + + state = hass.states.get("sensor.monitor_energy_production_price_low") + entry = entity_registry.async_get("sensor.monitor_energy_production_price_low") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_settings_energy_production_price_low" + assert state.state == "0.20522" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production Price - Low" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_settings")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "Settings" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +@pytest.mark.parametrize( + "entity_id", + ("sensor.monitor_gas_consumption",), +) +async def test_smartmeter_disabled_by_default( + hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str +) -> None: + """Test the P1 Monitor - SmartMeter sensors that are disabled by default.""" + entity_registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state is None + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index bd2fc6a7fa8..6ed8eaaa94a 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -239,7 +239,7 @@ def plextv_resources_base_fixture(): @pytest.fixture(name="plextv_resources", scope="session") def plextv_resources_fixture(plextv_resources_base): """Load default payload for plex.tv resources and return it.""" - return plextv_resources_base.format(second_server_enabled=0) + return plextv_resources_base.format(first_server_enabled=1, second_server_enabled=0) @pytest.fixture(name="plextv_shared_users", scope="session") diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 72958fc10c0..45904588a10 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -210,7 +210,9 @@ async def test_multiple_servers_with_selection( requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format(second_server_enabled=1), + text=plextv_resources_base.format( + first_server_enabled=1, second_server_enabled=1 + ), ) with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN @@ -248,6 +250,42 @@ async def test_multiple_servers_with_selection( assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN +async def test_only_non_present_servers( + hass, + mock_plex_calls, + requests_mock, + plextv_resources_base, + current_request_with_host, +): + """Test creating an entry with one server available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + requests_mock.get( + "https://plex.tv/api/resources", + text=plextv_resources_base.format( + first_server_enabled=0, second_server_enabled=0 + ), + ) + with patch("plexauth.PlexAuth.initiate_auth"), patch( + "plexauth.PlexAuth.token", return_value=MOCK_TOKEN + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "external" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "external_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "form" + assert result["step_id"] == "select_server" + + async def test_adding_last_unconfigured_server( hass, mock_plex_calls, @@ -272,7 +310,9 @@ async def test_adding_last_unconfigured_server( requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format(second_server_enabled=1), + text=plextv_resources_base.format( + first_server_enabled=1, second_server_enabled=1 + ), ) with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -332,7 +372,9 @@ async def test_all_available_servers_configured( requests_mock.get("https://plex.tv/users/account", text=plextv_account) requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format(second_server_enabled=1), + text=plextv_resources_base.format( + first_server_enabled=1, second_server_enabled=1 + ), ) with patch("plexauth.PlexAuth.initiate_auth"), patch( diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 32c7da9c78e..33c186e922c 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -39,8 +39,6 @@ async def test_sensors(hass): assert state.state == "0.032" expected_attributes = { "frequency": 60, - "energy_exported_(in_kW)": 10429.5, - "energy_imported_(in_kW)": 4824.2, "instant_average_voltage": 120.7, "unit_of_measurement": "kW", "friendly_name": "Powerwall Site Now", @@ -52,12 +50,16 @@ async def test_sensors(hass): for key, value in expected_attributes.items(): assert state.attributes[key] == value + assert float(hass.states.get("sensor.powerwall_site_export").state) == 10429.5 + assert float(hass.states.get("sensor.powerwall_site_import").state) == 4824.2 + + export_attributes = hass.states.get("sensor.powerwall_site_export").attributes + assert export_attributes["unit_of_measurement"] == "kWh" + state = hass.states.get("sensor.powerwall_load_now") assert state.state == "1.971" expected_attributes = { "frequency": 60, - "energy_exported_(in_kW)": 1056.8, - "energy_imported_(in_kW)": 4693.0, "instant_average_voltage": 120.7, "unit_of_measurement": "kW", "friendly_name": "Powerwall Load Now", @@ -69,12 +71,13 @@ async def test_sensors(hass): for key, value in expected_attributes.items(): assert state.attributes[key] == value + assert float(hass.states.get("sensor.powerwall_load_export").state) == 1056.8 + assert float(hass.states.get("sensor.powerwall_load_import").state) == 4693.0 + state = hass.states.get("sensor.powerwall_battery_now") assert state.state == "-8.55" expected_attributes = { "frequency": 60.0, - "energy_exported_(in_kW)": 3620.0, - "energy_imported_(in_kW)": 4216.2, "instant_average_voltage": 240.6, "unit_of_measurement": "kW", "friendly_name": "Powerwall Battery Now", @@ -86,12 +89,13 @@ async def test_sensors(hass): for key, value in expected_attributes.items(): assert state.attributes[key] == value + assert float(hass.states.get("sensor.powerwall_battery_export").state) == 3620.0 + assert float(hass.states.get("sensor.powerwall_battery_import").state) == 4216.2 + state = hass.states.get("sensor.powerwall_solar_now") assert state.state == "10.49" expected_attributes = { "frequency": 60, - "energy_exported_(in_kW)": 9864.2, - "energy_imported_(in_kW)": 28.2, "instant_average_voltage": 120.7, "unit_of_measurement": "kW", "friendly_name": "Powerwall Solar Now", @@ -103,6 +107,9 @@ async def test_sensors(hass): for key, value in expected_attributes.items(): assert state.attributes[key] == value + assert float(hass.states.get("sensor.powerwall_solar_export").state) == 9864.2 + assert float(hass.states.get("sensor.powerwall_solar_import").state) == 28.2 + state = hass.states.get("sensor.powerwall_charge") assert state.state == "47" expected_attributes = { diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index f8fcdd4561a..6f89c91a245 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -137,6 +137,24 @@ async def test_view_empty_namespace(hass, hass_client): 'friendly_name="HeatPump"} 25.0' in body ) + assert ( + 'climate_target_temperature_celsius{domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 20.0' in body + ) + + assert ( + 'climate_target_temperature_low_celsius{domain="climate",' + 'entity="climate.ecobee",' + 'friendly_name="Ecobee"} 21.0' in body + ) + + assert ( + 'climate_target_temperature_high_celsius{domain="climate",' + 'entity="climate.ecobee",' + 'friendly_name="Ecobee"} 24.0' in body + ) + assert ( 'humidifier_target_humidity_percent{domain="humidifier",' 'entity="humidifier.humidifier",' diff --git a/tests/components/prosegur/test_config_flow.py b/tests/components/prosegur/test_config_flow.py index bece0bae621..f345d4c7fa3 100644 --- a/tests/components/prosegur/test_config_flow.py +++ b/tests/components/prosegur/test_config_flow.py @@ -60,7 +60,7 @@ async def test_form_invalid_auth(hass): ) with patch( - "pyprosegur.auth.Auth", + "pyprosegur.installation.Installation", side_effect=ConnectionRefusedError, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 142d833698d..1e1f24b6eee 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -179,6 +179,20 @@ for i in [1, 2]: assert hass.states.is_state("hello.2", "world") +async def test_using_enumerate(hass): + """Test that enumerate is accepted and executed.""" + source = """ +for index, value in enumerate(["earth", "mars"]): + hass.states.set('hello.{}'.format(index), value) + """ + + hass.async_add_job(execute, hass, "test.py", source, {}) + await hass.async_block_till_done() + + assert hass.states.is_state("hello.0", "earth") + assert hass.states.is_state("hello.1", "mars") + + async def test_unpacking_sequence(hass, caplog): """Test compile error logs error.""" caplog.set_level(logging.ERROR) diff --git a/tests/components/rainforest_eagle/__init__.py b/tests/components/rainforest_eagle/__init__.py new file mode 100644 index 00000000000..df4f1749d49 --- /dev/null +++ b/tests/components/rainforest_eagle/__init__.py @@ -0,0 +1 @@ +"""Tests for the Rainforest Eagle integration.""" diff --git a/tests/components/rainforest_eagle/test_config_flow.py b/tests/components/rainforest_eagle/test_config_flow.py new file mode 100644 index 00000000000..a54fbdac4db --- /dev/null +++ b/tests/components/rainforest_eagle/test_config_flow.py @@ -0,0 +1,99 @@ +"""Test the Rainforest Eagle config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_200, +) +from homeassistant.components.rainforest_eagle.data import CannotConnect, InvalidAuth +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +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( + "homeassistant.components.rainforest_eagle.data.async_get_type", + return_value=(TYPE_EAGLE_200, "mock-hw"), + ), patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "abcdef" + assert result2["data"] == { + CONF_TYPE: TYPE_EAGLE_200, + CONF_HOST: "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: "mock-hw", + } + 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( + "aioeagle.EagleHub.get_device_list", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + 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( + "aioeagle.EagleHub.get_device_list", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/rainforest_eagle/test_init.py b/tests/components/rainforest_eagle/test_init.py new file mode 100644 index 00000000000..0c3305732cb --- /dev/null +++ b/tests/components/rainforest_eagle/test_init.py @@ -0,0 +1,65 @@ +"""Tests for the Rainforest Eagle integration.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_200, +) +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT +from homeassistant.setup import async_setup_component + + +async def test_import(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.rainforest_eagle.data.async_get_type", + return_value=(TYPE_EAGLE_200, "mock-hw"), + ), patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": DOMAIN, + "ip_address": "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + } + }, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + assert entry.title == "abcdef" + assert entry.data == { + CONF_TYPE: TYPE_EAGLE_200, + CONF_HOST: "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: "mock-hw", + } + assert len(mock_setup_entry.mock_calls) == 1 + + # Second time we should get already_configured + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + data={CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456"}, + context={"source": config_entries.SOURCE_IMPORT}, + ) + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py new file mode 100644 index 00000000000..e895f2ac4fc --- /dev/null +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -0,0 +1,163 @@ +"""Tests for rainforest eagle sensors.""" +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_CLOUD_ID = "12345" +MOCK_200_RESPONSE_WITH_PRICE = { + "zigbee:InstantaneousDemand": { + "Name": "zigbee:InstantaneousDemand", + "Value": "1.152000", + }, + "zigbee:CurrentSummationDelivered": { + "Name": "zigbee:CurrentSummationDelivered", + "Value": "45251.285000", + }, + "zigbee:CurrentSummationReceived": { + "Name": "zigbee:CurrentSummationReceived", + "Value": "232.232000", + }, + "zigbee:Price": {"Name": "zigbee:Price", "Value": "0.053990"}, + "zigbee:PriceCurrency": {"Name": "zigbee:PriceCurrency", "Value": "USD"}, +} +MOCK_200_RESPONSE_WITHOUT_PRICE = { + "zigbee:InstantaneousDemand": { + "Name": "zigbee:InstantaneousDemand", + "Value": "1.152000", + }, + "zigbee:CurrentSummationDelivered": { + "Name": "zigbee:CurrentSummationDelivered", + "Value": "45251.285000", + }, + "zigbee:CurrentSummationReceived": { + "Name": "zigbee:CurrentSummationReceived", + "Value": "232.232000", + }, + "zigbee:Price": {"Name": "zigbee:Price", "Value": "invalid"}, + "zigbee:PriceCurrency": {"Name": "zigbee:PriceCurrency", "Value": "USD"}, +} + + +@pytest.fixture +async def setup_rainforest_200(hass): + """Set up rainforest.""" + MockConfigEntry( + domain="rainforest_eagle", + data={ + CONF_CLOUD_ID: MOCK_CLOUD_ID, + CONF_HOST: "192.168.1.55", + CONF_INSTALL_CODE: "abcdefgh", + CONF_HARDWARE_ADDRESS: "mock-hw-address", + CONF_TYPE: TYPE_EAGLE_200, + }, + ).add_to_hass(hass) + with patch( + "aioeagle.ElectricMeter.create_instance", + return_value=Mock( + get_device_query=AsyncMock(return_value=MOCK_200_RESPONSE_WITHOUT_PRICE) + ), + ) as mock_update: + mock_update.return_value.is_connected = True + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_rainforest_100(hass): + """Set up rainforest.""" + MockConfigEntry( + domain="rainforest_eagle", + data={ + CONF_CLOUD_ID: MOCK_CLOUD_ID, + CONF_HOST: "192.168.1.55", + CONF_INSTALL_CODE: "abcdefgh", + CONF_HARDWARE_ADDRESS: None, + CONF_TYPE: TYPE_EAGLE_100, + }, + ).add_to_hass(hass) + with patch( + "homeassistant.components.rainforest_eagle.data.Eagle100Reader", + return_value=Mock( + get_instantaneous_demand=Mock( + return_value={"InstantaneousDemand": {"Demand": "1.152000"}} + ), + get_current_summation=Mock( + return_value={ + "CurrentSummation": { + "SummationDelivered": "45251.285000", + "SummationReceived": "232.232000", + } + } + ), + ), + ) as mock_update: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update + + +async def test_sensors_200(hass, setup_rainforest_200): + """Test the sensors.""" + assert len(hass.states.async_all()) == 3 + + demand = hass.states.get("sensor.eagle_200_meter_power_demand") + assert demand is not None + assert demand.state == "1.152000" + assert demand.attributes["unit_of_measurement"] == "kW" + + delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") + assert delivered is not None + assert delivered.state == "45251.285000" + assert delivered.attributes["unit_of_measurement"] == "kWh" + + received = hass.states.get("sensor.eagle_200_total_meter_energy_received") + assert received is not None + assert received.state == "232.232000" + assert received.attributes["unit_of_measurement"] == "kWh" + + setup_rainforest_200.get_device_query.return_value = MOCK_200_RESPONSE_WITH_PRICE + + 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() + + assert len(hass.states.async_all()) == 4 + + price = hass.states.get("sensor.meter_price") + assert price is not None + assert price.state == "0.053990" + assert price.attributes["unit_of_measurement"] == "USD/kWh" + + +async def test_sensors_100(hass, setup_rainforest_100): + """Test the sensors.""" + assert len(hass.states.async_all()) == 3 + + demand = hass.states.get("sensor.eagle_200_meter_power_demand") + assert demand is not None + assert demand.state == "1.152000" + assert demand.attributes["unit_of_measurement"] == "kW" + + delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") + assert delivered is not None + assert delivered.state == "45251.285000" + assert delivered.attributes["unit_of_measurement"] == "kWh" + + received = hass.states.get("sensor.eagle_200_total_meter_energy_received") + assert received is not None + assert received.state == "232.232000" + assert received.attributes["unit_of_measurement"] == "kWh" diff --git a/tests/components/recollect_waste/test_config_flow.py b/tests/components/recollect_waste/test_config_flow.py index cabcb1a8f9e..22f32983055 100644 --- a/tests/components/recollect_waste/test_config_flow.py +++ b/tests/components/recollect_waste/test_config_flow.py @@ -36,7 +36,7 @@ async def test_invalid_place_or_service_id(hass): conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} with patch( - "aiorecollect.client.Client.async_get_next_pickup_event", + "aiorecollect.client.Client.async_get_pickup_events", side_effect=RecollectError, ): result = await hass.config_entries.flow.async_init( @@ -87,9 +87,7 @@ async def test_step_import(hass): with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch( - "aiorecollect.client.Client.async_get_next_pickup_event", 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 ) @@ -105,9 +103,7 @@ async def test_step_user(hass): with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch( - "aiorecollect.client.Client.async_get_next_pickup_event", 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_USER}, data=conf ) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 195e56dc748..fa0e8b7349b 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -25,7 +25,13 @@ from homeassistant.components.recorder import ( run_information_with_session, ) from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import Events, RecorderRuns, States +from homeassistant.components.recorder.models import ( + Events, + RecorderRuns, + States, + StatisticsRuns, + process_timestamp, +) from homeassistant.components.recorder.util import session_scope from homeassistant.const import ( EVENT_HOMEASSISTANT_FINAL_WRITE, @@ -735,6 +741,69 @@ def test_auto_statistics(hass_recorder): dt_util.set_default_time_zone(original_tz) +def test_statistics_runs_initiated(hass_recorder): + """Test statistics_runs is initiated when DB is created.""" + now = dt_util.utcnow() + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=now): + hass = hass_recorder() + + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + 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) + + +def test_compile_missing_statistics(tmpdir): + """Test missing statistics are compiled on startup.""" + now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=now): + + hass = get_test_home_assistant() + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 1 + last_run = process_timestamp(statistics_runs[0].start) + assert last_run == now - timedelta(hours=1) + + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() + + with patch( + "homeassistant.components.recorder.dt_util.utcnow", + return_value=now + timedelta(hours=1), + ): + + hass = get_test_home_assistant() + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 2 + last_run = process_timestamp(statistics_runs[1].start) + assert last_run == now + + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() + + def test_saving_sets_old_state(hass_recorder): """Test saving sets old state.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 0468cc26a23..318d82422d7 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -151,6 +151,39 @@ def test_rename_entity(hass_recorder): assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} +def test_statistics_duplicated(hass_recorder, caplog): + """Test statistics with same start time is not compiled.""" + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + with patch( + "homeassistant.components.sensor.recorder.compile_statistics" + ) as compile_statistics: + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert compile_statistics.called + compile_statistics.reset_mock() + assert "Compiling statistics for" in caplog.text + assert "Statistics already compiled" not in caplog.text + caplog.clear() + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert not compile_statistics.called + compile_statistics.reset_mock() + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" in caplog.text + caplog.clear() + + def record_states(hass): """Record some test states. diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 5b4b234fbbb..cb54f0404b9 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from sqlalchemy import text +from sqlalchemy.sql.elements import TextClause from homeassistant.components.recorder import run_information_with_session, util from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX @@ -253,6 +254,11 @@ def test_end_incomplete_runs(hass_recorder, caplog): def test_perodic_db_cleanups(hass_recorder): """Test perodic db cleanups.""" hass = hass_recorder() - with patch.object(hass.data[DATA_INSTANCE].engine, "execute") as execute_mock: + with patch.object(hass.data[DATA_INSTANCE].engine, "connect") as connect_mock: util.perodic_db_cleanups(hass.data[DATA_INSTANCE]) - assert execute_mock.call_args[0][0] == "PRAGMA wal_checkpoint(TRUNCATE);" + + text_obj = connect_mock.return_value.__enter__.return_value.execute.mock_calls[0][ + 1 + ][0] + assert isinstance(text_obj, TextClause) + assert str(text_obj) == "PRAGMA wal_checkpoint(TRUNCATE);" diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 1193764da3a..48e741a12a4 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -2,9 +2,6 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.components.device_automation import ( - _async_get_device_automations as async_get_device_automations, -) from homeassistant.components.remote import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -12,6 +9,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, + async_get_device_automations, async_mock_service, mock_device_registry, mock_registry, diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index e4edc3b8539..9191851c777 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -1,159 +1,242 @@ """Tests for the Renault integration.""" from __future__ import annotations -from datetime import timedelta from typing import Any from unittest.mock import patch -from renault_api.kamereon import models, schemas -from renault_api.renault_vehicle import RenaultVehicle +from renault_api.kamereon import schemas +from renault_api.renault_account import RenaultAccount -from homeassistant.components.renault.const import ( - CONF_KAMEREON_ACCOUNT_ID, - CONF_LOCALE, - DOMAIN, +from homeassistant.components.renault.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, ) -from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import DeviceRegistry -from .const import MOCK_VEHICLES +from .const import MOCK_CONFIG, MOCK_VEHICLES from tests.common import MockConfigEntry, load_fixture -async def setup_renault_integration(hass: HomeAssistant): +def get_mock_config_entry(): """Create the Renault integration.""" - config_entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, - source="user", - data={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - CONF_KAMEREON_ACCOUNT_ID: "account_id_2", - }, - unique_id="account_id_2", + source=SOURCE_USER, + data=MOCK_CONFIG, + unique_id="account_id_1", options={}, - entry_id="1", + entry_id="123456", ) + + +def get_fixtures(vehicle_type: str) -> dict[str, Any]: + """Create a vehicle proxy for testing.""" + mock_vehicle = MOCK_VEHICLES.get(vehicle_type, {"endpoints": {}}) + return { + "battery_status": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['battery_status']}") + if "battery_status" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), + "charge_mode": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['charge_mode']}") + if "charge_mode" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleChargeModeDataSchema), + "cockpit": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['cockpit']}") + if "cockpit" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleCockpitDataSchema), + "hvac_status": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['hvac_status']}") + if "hvac_status" in mock_vehicle["endpoints"] + else load_fixture("renault/no_data.json") + ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), + } + + +async def setup_renault_integration_simple(hass: HomeAssistant): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.renault.RenaultHub.attempt_login", return_value=True - ), patch("homeassistant.components.renault.RenaultHub.async_initialise"): + renault_account = RenaultAccount( + config_entry.unique_id, + websession=aiohttp_client.async_get_clientsession(hass), + ) + + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch("renault_api.renault_account.RenaultAccount.get_vehicles"): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry -def get_fixtures(vehicle_type: str) -> dict[str, Any]: - """Create a vehicle proxy for testing.""" - mock_vehicle = MOCK_VEHICLES[vehicle_type] - return { - "battery_status": schemas.KamereonVehicleDataResponseSchema.loads( - load_fixture(f"renault/{mock_vehicle['endpoints']['battery_status']}") - if "battery_status" in mock_vehicle["endpoints"] - else "{}" - ).get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), - "charge_mode": schemas.KamereonVehicleDataResponseSchema.loads( - load_fixture(f"renault/{mock_vehicle['endpoints']['charge_mode']}") - if "charge_mode" in mock_vehicle["endpoints"] - else "{}" - ).get_attributes(schemas.KamereonVehicleChargeModeDataSchema), - "cockpit": schemas.KamereonVehicleDataResponseSchema.loads( - load_fixture(f"renault/{mock_vehicle['endpoints']['cockpit']}") - if "cockpit" in mock_vehicle["endpoints"] - else "{}" - ).get_attributes(schemas.KamereonVehicleCockpitDataSchema), - "hvac_status": schemas.KamereonVehicleDataResponseSchema.loads( - load_fixture(f"renault/{mock_vehicle['endpoints']['hvac_status']}") - if "hvac_status" in mock_vehicle["endpoints"] - else "{}" - ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), - } +async def setup_renault_integration_vehicle(hass: HomeAssistant, vehicle_type: str): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) - -async def create_vehicle_proxy( - hass: HomeAssistant, vehicle_type: str -) -> RenaultVehicleProxy: - """Create a vehicle proxy for testing.""" + renault_account = RenaultAccount( + config_entry.unique_id, + websession=aiohttp_client.async_get_clientsession(hass), + ) mock_vehicle = MOCK_VEHICLES[vehicle_type] mock_fixtures = get_fixtures(vehicle_type) - vehicles_response: models.KamereonVehiclesResponse = ( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture(f"renault/vehicle_{vehicle_type}.json") - ) - ) - vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails - vehicle = RenaultVehicle( - vehicles_response.accountId, - vehicle_details.vin, - websession=aiohttp_client.async_get_clientsession(hass), - ) - - vehicle_proxy = RenaultVehicleProxy( - hass, vehicle, vehicle_details, timedelta(seconds=300) - ) - with patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.supports_endpoint", side_effect=mock_vehicle["endpoints_available"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + "renault_api.renault_vehicle.RenaultVehicle.has_contract_for_endpoint", + return_value=True, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", return_value=mock_fixtures["battery_status"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", return_value=mock_fixtures["charge_mode"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", return_value=mock_fixtures["cockpit"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", return_value=mock_fixtures["hvac_status"], ): - await vehicle_proxy.async_initialise() - return vehicle_proxy + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry -async def create_vehicle_proxy_with_side_effect( - hass: HomeAssistant, vehicle_type: str, side_effect: Any -) -> RenaultVehicleProxy: - """Create a vehicle proxy for testing unavailable entities.""" - mock_vehicle = MOCK_VEHICLES[vehicle_type] +async def setup_renault_integration_vehicle_with_no_data( + hass: HomeAssistant, vehicle_type: str +): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) - vehicles_response: models.KamereonVehiclesResponse = ( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture(f"renault/vehicle_{vehicle_type}.json") - ) - ) - vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails - vehicle = RenaultVehicle( - vehicles_response.accountId, - vehicle_details.vin, + renault_account = RenaultAccount( + config_entry.unique_id, websession=aiohttp_client.async_get_clientsession(hass), ) + mock_vehicle = MOCK_VEHICLES[vehicle_type] + mock_fixtures = get_fixtures("") - vehicle_proxy = RenaultVehicleProxy( - hass, vehicle, vehicle_details, timedelta(seconds=300) - ) - with patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.supports_endpoint", side_effect=mock_vehicle["endpoints_available"], ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + "renault_api.renault_vehicle.RenaultVehicle.has_contract_for_endpoint", + return_value=True, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", + return_value=mock_fixtures["battery_status"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", + return_value=mock_fixtures["charge_mode"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", + return_value=mock_fixtures["cockpit"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", + return_value=mock_fixtures["hvac_status"], + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +async def setup_renault_integration_vehicle_with_side_effect( + hass: HomeAssistant, vehicle_type: str, side_effect: Any +): + """Create the Renault integration.""" + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) + + renault_account = RenaultAccount( + config_entry.unique_id, + websession=aiohttp_client.async_get_clientsession(hass), + ) + mock_vehicle = MOCK_VEHICLES[vehicle_type] + + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ), patch( + "renault_api.renault_account.RenaultAccount.get_vehicles", + return_value=( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ), + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.supports_endpoint", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.has_contract_for_endpoint", + return_value=True, + ), patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", side_effect=side_effect, ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", side_effect=side_effect, ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", side_effect=side_effect, ), patch( - "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", side_effect=side_effect, ): - await vehicle_proxy.async_initialise() - return vehicle_proxy + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +def check_device_registry( + device_registry: DeviceRegistry, expected_device: dict[str, Any] +) -> None: + """Ensure that the expected_device is correctly registered.""" + assert len(device_registry.devices) == 1 + registry_entry = device_registry.async_get_device(expected_device[ATTR_IDENTIFIERS]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] + assert registry_entry.manufacturer == expected_device[ATTR_MANUFACTURER] + assert registry_entry.name == expected_device[ATTR_NAME] + assert registry_entry.model == expected_device[ATTR_MODEL] + assert registry_entry.sw_version == expected_device[ATTR_SW_VERSION] diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index be2adafd7be..2c742aa07cd 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -1,4 +1,9 @@ """Constants for the Renault integration tests.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + DOMAIN as BINARY_SENSOR_DOMAIN, +) from homeassistant.components.renault.const import ( CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, @@ -13,10 +18,14 @@ from homeassistant.const import ( CONF_USERNAME, DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, POWER_KILO_WATT, + STATE_OFF, + STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS, TIME_MINUTES, @@ -52,6 +61,20 @@ MOCK_VEHICLES = { "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777999_plugged_in", + "result": STATE_ON, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777999_charging", + "result": STATE_ON, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -59,6 +82,13 @@ MOCK_VEHICLES = { "result": "141", "unit": 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, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", @@ -90,7 +120,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_charging_power", "result": "0.027", "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", @@ -138,6 +168,20 @@ MOCK_VEHICLES = { "charge_mode": "charge_mode_schedule.json", "cockpit": "cockpit_ev.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777999_plugged_in", + "result": STATE_OFF, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777999_charging", + "result": STATE_OFF, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -145,6 +189,13 @@ MOCK_VEHICLES = { "result": "128", "unit": 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, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777999_battery_level", @@ -176,7 +227,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777999_charging_power", "result": STATE_UNKNOWN, "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", @@ -217,6 +268,20 @@ MOCK_VEHICLES = { "charge_mode": "charge_mode_always.json", "cockpit": "cockpit_fuel.json", }, + BINARY_SENSOR_DOMAIN: [ + { + "entity_id": "binary_sensor.plugged_in", + "unique_id": "vf1aaaaa555777123_plugged_in", + "result": STATE_ON, + "class": DEVICE_CLASS_PLUG, + }, + { + "entity_id": "binary_sensor.charging", + "unique_id": "vf1aaaaa555777123_charging", + "result": STATE_ON, + "class": DEVICE_CLASS_BATTERY_CHARGING, + }, + ], SENSOR_DOMAIN: [ { "entity_id": "sensor.battery_autonomy", @@ -224,6 +289,13 @@ MOCK_VEHICLES = { "result": "141", "unit": 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, + }, { "entity_id": "sensor.battery_level", "unique_id": "vf1aaaaa555777123_battery_level", @@ -255,7 +327,7 @@ MOCK_VEHICLES = { "unique_id": "vf1aaaaa555777123_charging_power", "result": "27.0", "unit": POWER_KILO_WATT, - "class": DEVICE_CLASS_ENERGY, + "class": DEVICE_CLASS_POWER, }, { "entity_id": "sensor.charging_remaining_time", @@ -304,6 +376,7 @@ MOCK_VEHICLES = { # Ignore, # charge-mode ], "endpoints": {"cockpit": "cockpit_fuel.json"}, + BINARY_SENSOR_DOMAIN: [], SENSOR_DOMAIN: [ { "entity_id": "sensor.fuel_autonomy", diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py new file mode 100644 index 00000000000..71bb90f16a6 --- /dev/null +++ b/tests/components/renault/test_binary_sensor.py @@ -0,0 +1,155 @@ +"""Tests for Renault binary sensors.""" +from unittest.mock import patch + +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.setup import async_setup_component + +from . import ( + check_device_registry, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, +) +from .const import MOCK_VEHICLES + +from tests.common import mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensors(hass, vehicle_type): + """Test for Renault binary sensors.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_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[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensor_empty(hass, vehicle_type): + """Test for Renault binary sensors 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", [BINARY_SENSOR_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[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_binary_sensor_errors(hass, vehicle_type): + """Test for Renault binary sensors with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + with patch("homeassistant.components.renault.PLATFORMS", [BINARY_SENSOR_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[BINARY_SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +async def test_binary_sensor_access_denied(hass): + """Test for Renault binary sensors 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", [BINARY_SENSOR_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_binary_sensor_not_supported(hass): + """Test for Renault binary sensors 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", [BINARY_SENSOR_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_config_flow.py b/tests/components/renault/test_config_flow.py index c8b9c8c3e12..684e17a0101 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, PropertyMock, patch from renault_api.gigya.exceptions import InvalidCredentialsException from renault_api.kamereon import schemas +from renault_api.renault_account import RenaultAccount from homeassistant import config_entries, data_entry_flow from homeassistant.components.renault.const import ( @@ -12,126 +13,197 @@ from homeassistant.components.renault.const import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from . import get_mock_config_entry from tests.common import load_fixture async def test_config_flow_single_account(hass: HomeAssistant): """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {} - - # Failed credentials with patch( - "renault_api.renault_session.RenaultSession.login", - side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - }, + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Failed credentials + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException( + 403042, "invalid loginID or password" + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_credentials"} + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_1") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_credentials"} + # Account list single + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="123" + ), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) - renault_account = AsyncMock() - type(renault_account).account_id = PropertyMock(return_value="account_id_1") - renault_account.get_vehicles.return_value = ( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture("renault/vehicle_zoe_40.json") - ) - ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_1" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert result["data"][CONF_LOCALE] == "fr_FR" - # Account list single - with patch("renault_api.renault_session.RenaultSession.login"), patch( - "renault_api.renault_account.RenaultAccount.account_id", return_value="123" - ), patch( - "renault_api.renault_client.RenaultClient.get_api_accounts", - return_value=[renault_account], - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "account_id_1" - assert result["data"][CONF_USERNAME] == "email@test.com" - assert result["data"][CONF_PASSWORD] == "test" - assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" - assert result["data"][CONF_LOCALE] == "fr_FR" + assert len(mock_setup_entry.mock_calls) == 1 async def test_config_flow_no_account(hass: HomeAssistant): """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {} - - # Account list empty - with patch("renault_api.renault_session.RenaultSession.login"), patch( - "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", - return_value=[], - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - }, + with patch( + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "kamereon_no_account" + # Account list empty + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "kamereon_no_account" + + assert len(mock_setup_entry.mock_calls) == 0 async def test_config_flow_multiple_accounts(hass: HomeAssistant): """Test what happens if multiple Kamereon accounts are available.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {} + with patch( + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} - # Multiple accounts - with patch("renault_api.renault_session.RenaultSession.login"), patch( - "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", - return_value=["account_id_1", "account_id_2"], - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCALE: "fr_FR", - CONF_USERNAME: "email@test.com", - CONF_PASSWORD: "test", - }, + renault_account_1 = RenaultAccount( + "account_id_1", + websession=aiohttp_client.async_get_clientsession(hass), + ) + renault_account_2 = RenaultAccount( + "account_id_2", + websession=aiohttp_client.async_get_clientsession(hass), ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "kamereon" + # Multiple accounts + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account_1, renault_account_2], + ), patch("renault_api.renault_account.RenaultAccount.get_vehicles"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) - # Account selected - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "account_id_2" - assert result["data"][CONF_USERNAME] == "email@test.com" - assert result["data"][CONF_PASSWORD] == "test" - assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2" - assert result["data"][CONF_LOCALE] == "fr_FR" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "kamereon" + + # Account selected + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_2" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2" + assert result["data"][CONF_LOCALE] == "fr_FR" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_duplicate(hass: HomeAssistant): + """Test abort if unique_id configured.""" + with patch( + "homeassistant.components.renault.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + get_mock_config_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_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + renault_account = RenaultAccount( + "account_id_1", + websession=aiohttp_client.async_get_clientsession(hass), + ) + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ), patch("renault_api.renault_account.RenaultAccount.get_vehicles"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index 974155c3df9..37a67151972 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -1,85 +1,63 @@ """Tests for Renault setup process.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import aiohttp -import pytest from renault_api.gigya.exceptions import InvalidCredentialsException -from renault_api.kamereon import schemas -from homeassistant.components.renault import ( - RenaultHub, - async_setup_entry, - async_unload_entry, -) from homeassistant.components.renault.const import DOMAIN -from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.config_entries import ConfigEntryState -from .const import MOCK_CONFIG - -from tests.common import MockConfigEntry, load_fixture +from . import get_mock_config_entry, setup_renault_integration_simple -async def test_setup_unload_and_reload_entry(hass): +async def test_setup_unload_entry(hass): """Test entry setup and unload.""" - # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 - ) - renault_account = AsyncMock() - renault_account.get_vehicles.return_value = ( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture("renault/vehicle_zoe_40.json") - ) - ) + with patch("homeassistant.components.renault.PLATFORMS", []): + config_entry = await setup_renault_integration_simple(hass) - with patch("renault_api.renault_session.RenaultSession.login"), patch( - "renault_api.renault_client.RenaultClient.get_api_account", - return_value=renault_account, - ): - # Set up the entry and assert that the values set during setup are where we expect - # them to be. - assert await async_setup_entry(hass, config_entry) - assert DOMAIN in hass.data and config_entry.unique_id in hass.data[DOMAIN] - assert isinstance(hass.data[DOMAIN][config_entry.unique_id], RenaultHub) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.entry_id in hass.data[DOMAIN] - renault_hub: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] - assert len(renault_hub.vehicles) == 1 - assert isinstance( - renault_hub.vehicles["VF1AAAAA555777999"], RenaultVehicleProxy - ) - - # Unload the entry and verify that the data has been removed - assert await async_unload_entry(hass, config_entry) - assert config_entry.unique_id not in hass.data[DOMAIN] + # Unload the entry and verify that the data has been removed + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry.entry_id not in hass.data[DOMAIN] async def test_setup_entry_bad_password(hass): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 - ) + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) with patch( "renault_api.renault_session.RenaultSession.login", side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), ): - # Set up the entry and assert that the values set during setup are where we expect - # them to be. - assert not await async_setup_entry(hass, config_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) async def test_setup_entry_exception(hass): """Test ConfigEntryNotReady when API raises an exception during entry setup.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 - ) + config_entry = get_mock_config_entry() + config_entry.add_to_hass(hass) # In this case we are testing the condition where async_setup_entry raises # ConfigEntryNotReady. with patch( "renault_api.renault_session.RenaultSession.login", side_effect=aiohttp.ClientConnectionError, - ), pytest.raises(ConfigEntryNotReady): - assert await async_setup_entry(hass, config_entry) + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 8956fa7e7e6..41fceccb56c 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,17 +1,18 @@ """Tests for Renault sensors.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import patch import pytest from renault_api.kamereon import exceptions from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.setup import async_setup_component from . import ( - create_vehicle_proxy, - create_vehicle_proxy_with_side_effect, - setup_renault_integration, + check_device_registry, + setup_renault_integration_vehicle, + setup_renault_integration_vehicle_with_no_data, + setup_renault_integration_vehicle_with_side_effect, ) from .const import MOCK_VEHICLES @@ -25,28 +26,12 @@ async def test_sensors(hass, vehicle_type): entity_registry = mock_registry(hass) device_registry = mock_device_registry(hass) - vehicle_proxy = await create_vehicle_proxy(hass, vehicle_type) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle(hass, vehicle_type) await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] - assert len(device_registry.devices) == 1 - expected_device = mock_vehicle["expected_device"] - registry_entry = device_registry.async_get_device(expected_device["identifiers"]) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device["identifiers"] - assert registry_entry.manufacturer == expected_device["manufacturer"] - assert registry_entry.name == expected_device["name"] - assert registry_entry.model == expected_device["model"] - assert registry_entry.sw_version == expected_device["sw_version"] + check_device_registry(device_registry, mock_vehicle["expected_device"]) expected_entities = mock_vehicle[SENSOR_DOMAIN] assert len(entity_registry.entities) == len(expected_entities) @@ -68,28 +53,12 @@ async def test_sensor_empty(hass, vehicle_type): entity_registry = mock_registry(hass) device_registry = mock_device_registry(hass) - vehicle_proxy = await create_vehicle_proxy_with_side_effect(hass, vehicle_type, {}) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_no_data(hass, vehicle_type) await hass.async_block_till_done() mock_vehicle = MOCK_VEHICLES[vehicle_type] - assert len(device_registry.devices) == 1 - expected_device = mock_vehicle["expected_device"] - registry_entry = device_registry.async_get_device(expected_device["identifiers"]) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device["identifiers"] - assert registry_entry.manufacturer == expected_device["manufacturer"] - assert registry_entry.name == expected_device["name"] - assert registry_entry.model == expected_device["model"] - assert registry_entry.sw_version == expected_device["sw_version"] + check_device_registry(device_registry, mock_vehicle["expected_device"]) expected_entities = mock_vehicle[SENSOR_DOMAIN] assert len(entity_registry.entities) == len(expected_entities) @@ -101,7 +70,7 @@ async def test_sensor_empty(hass, vehicle_type): 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 + assert state.state == STATE_UNKNOWN @pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) @@ -116,30 +85,14 @@ async def test_sensor_errors(hass, vehicle_type): "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", ) - vehicle_proxy = await create_vehicle_proxy_with_side_effect( - hass, vehicle_type, invalid_upstream_exception - ) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_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] - assert len(device_registry.devices) == 1 - expected_device = mock_vehicle["expected_device"] - registry_entry = device_registry.async_get_device(expected_device["identifiers"]) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device["identifiers"] - assert registry_entry.manufacturer == expected_device["manufacturer"] - assert registry_entry.name == expected_device["name"] - assert registry_entry.model == expected_device["model"] - assert registry_entry.sw_version == expected_device["sw_version"] + check_device_registry(device_registry, mock_vehicle["expected_device"]) expected_entities = mock_vehicle[SENSOR_DOMAIN] assert len(entity_registry.entities) == len(expected_entities) @@ -160,26 +113,21 @@ async def test_sensor_access_denied(hass): 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", ) - vehicle_proxy = await create_vehicle_proxy_with_side_effect( - hass, "zoe_40", access_denied_exception - ) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, access_denied_exception + ) await hass.async_block_till_done() - assert len(device_registry.devices) == 0 + mock_vehicle = MOCK_VEHICLES[vehicle_type] + check_device_registry(device_registry, mock_vehicle["expected_device"]) + assert len(entity_registry.entities) == 0 @@ -189,24 +137,19 @@ async def test_sensor_not_supported(hass): 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", ) - vehicle_proxy = await create_vehicle_proxy_with_side_effect( - hass, "zoe_40", not_supported_exception - ) - - with patch( - "homeassistant.components.renault.RenaultHub.vehicles", - new_callable=PropertyMock, - return_value={ - vehicle_proxy.details.vin: vehicle_proxy, - }, - ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): - await setup_renault_integration(hass) + with patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration_vehicle_with_side_effect( + hass, vehicle_type, not_supported_exception + ) await hass.async_block_till_done() - assert len(device_registry.devices) == 0 + 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/rituals_perfume_genie/common.py b/tests/components/rituals_perfume_genie/common.py new file mode 100644 index 00000000000..35555e2b842 --- /dev/null +++ b/tests/components/rituals_perfume_genie/common.py @@ -0,0 +1,94 @@ +"""Common methods used across tests for Rituals Perfume Genie.""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def mock_config_entry(uniqe_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, + data={ACCOUNT_HASH: "an_account_hash"}, + entry_id=entry_id, + ) + + +def mock_diffuser( + hublot: str, + available: bool = True, + battery_percentage: int | Exception = 100, + charging: bool | Exception = True, + fill: str = "90-100%", + has_battery: bool = True, + has_cartridge: bool = True, + is_on: bool = True, + name: str = "Genie", + perfume: str = "Ritual of Sakura", + version: str = "4.0", + wifi_percentage: int = 75, +) -> MagicMock: + """Return a mock Diffuser initialized with the given data.""" + diffuser_mock = MagicMock() + diffuser_mock.available = available + diffuser_mock.battery_percentage = battery_percentage + diffuser_mock.charging = charging + diffuser_mock.fill = fill + diffuser_mock.has_battery = has_battery + diffuser_mock.has_cartridge = has_cartridge + diffuser_mock.hublot = hublot + diffuser_mock.is_on = is_on + diffuser_mock.name = name + diffuser_mock.perfume = perfume + diffuser_mock.turn_off = AsyncMock() + diffuser_mock.turn_on = AsyncMock() + diffuser_mock.update_data = AsyncMock() + diffuser_mock.version = version + diffuser_mock.wifi_percentage = wifi_percentage + return diffuser_mock + + +def mock_diffuser_v1_battery_cartridge(): + """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(): + """Create and return a mock version 2 Diffuser without battery and cartridge.""" + return mock_diffuser( + hublot="lot123v2", + battery_percentage=Exception(), + charging=Exception(), + has_battery=False, + has_cartridge=False, + name="Genie V2", + perfume="No Cartridge", + version="5.0", + ) + + +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_diffusers: list[MagicMock] = [mock_diffuser(hublot="lot123")], +) -> None: + """Initialize the Rituals Perfume Genie integration with the given Config Entry and Diffuser list.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.rituals_perfume_genie.Account.get_devices", + return_value=mock_diffusers, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert hass.data[DOMAIN] + + await hass.async_block_till_done() diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py new file mode 100644 index 00000000000..887417a41f8 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -0,0 +1,34 @@ +"""Tests for the Rituals Perfume Genie integration.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant.components.rituals_perfume_genie.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +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.add_to_hass(hass) + with patch( + "homeassistant.components.rituals_perfume_genie.Account.get_devices", + side_effect=aiohttp.ClientError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +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") + await init_integration(hass, config_entry) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry.entry_id not in hass.data[DOMAIN] diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py new file mode 100644 index 00000000000..477353d3b83 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -0,0 +1,88 @@ +"""Tests for the Rituals Perfume Genie sensor platform.""" +from homeassistant.components.rituals_perfume_genie.sensor import ( + BATTERY_SUFFIX, + FILL_SUFFIX, + PERFUME_SUFFIX, + WIFI_SUFFIX, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_SIGNAL_STRENGTH, + PERCENTAGE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry + +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser_v1_battery_cartridge, + mock_diffuser_v2_no_battery_no_cartridge, +) + + +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") + 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("sensor.genie_perfume") + assert state + assert state.state == diffuser.perfume + assert state.attributes.get(ATTR_ICON) == "mdi:tag-text" + + entry = registry.async_get("sensor.genie_perfume") + assert entry + assert entry.unique_id == f"{hublot}{PERFUME_SUFFIX}" + + state = hass.states.get("sensor.genie_fill") + assert state + assert state.state == diffuser.fill + assert state.attributes.get(ATTR_ICON) == "mdi:beaker" + + entry = registry.async_get("sensor.genie_fill") + assert entry + assert entry.unique_id == f"{hublot}{FILL_SUFFIX}" + + state = hass.states.get("sensor.genie_battery") + assert state + assert state.state == str(diffuser.battery_percentage) + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + entry = registry.async_get("sensor.genie_battery") + assert entry + assert entry.unique_id == f"{hublot}{BATTERY_SUFFIX}" + + state = hass.states.get("sensor.genie_wifi") + assert state + assert state.state == str(diffuser.wifi_percentage) + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SIGNAL_STRENGTH + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + entry = registry.async_get("sensor.genie_wifi") + assert entry + assert entry.unique_id == f"{hublot}{WIFI_SUFFIX}" + + +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") + + await init_integration( + hass, config_entry, [mock_diffuser_v2_no_battery_no_cartridge()] + ) + + state = hass.states.get("sensor.genie_v2_perfume") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:tag-remove" + + state = hass.states.get("sensor.genie_v2_fill") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:beaker-question" diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py new file mode 100644 index 00000000000..a2691da0e0e --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -0,0 +1,104 @@ +"""Tests for the Rituals Perfume Genie switch platform.""" +from __future__ import annotations + +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.rituals_perfume_genie.const import COORDINATORS, DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +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_v1_battery_cartridge, +) + + +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") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + + registry = entity_registry.async_get(hass) + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ICON) == "mdi:fan" + + entry = registry.async_get("switch.genie") + assert entry + assert entry.unique_id == diffuser.hublot + + +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") + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + await async_setup_component(hass, "homeassistant", {}) + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]["lot123v1"] + diffuser.is_on = False + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_ON + + call_count_before_update = diffuser.update_data.call_count + + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["switch.genie"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_OFF + + assert coordinator.last_update_success + assert diffuser.update_data.call_count == call_count_before_update + 1 + + +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") + await init_integration(hass, config_entry, [mock_diffuser_v1_battery_cartridge()]) + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.genie"}, + blocking=True, + ) + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.genie"}, + blocking=True, + ) + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_ON diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index bebf1724761..cdb1c681f5c 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -213,7 +213,7 @@ async def test_form_user_no_devices_found_discovery_aborts_already_configured(ha result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + {CONF_HOST: MOCK_IP}, ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -250,10 +250,14 @@ async def test_form_user_discovery_manual_and_auto_password_fetch(hass): assert result2["errors"] is None assert result2["step_id"] == "manual" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_IP}, + ) + await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM assert result3["errors"] is None @@ -275,7 +279,7 @@ async def test_form_user_discovery_manual_and_auto_password_fetch(hass): await hass.async_block_till_done() assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"] == "myroomba" + assert result4["title"] == "robot_name" assert result4["result"].unique_id == "BLID" assert result4["data"] == { CONF_BLID: "BLID", @@ -309,7 +313,7 @@ async def test_form_user_discover_fails_aborts_already_configured(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + {CONF_HOST: MOCK_IP}, ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -322,12 +326,6 @@ async def test_form_user_discovery_manual_and_auto_password_fetch_but_cannot_con """Test discovery skipped and we can auto fetch the password then we fail to connect.""" await setup.async_setup_component(hass, "persistent_notification", {}) - mocked_roomba = _create_mocked_roomba( - connect=RoombaConnectionError, - roomba_connected=True, - master_state={"state": {"reported": {"name": "myroomba"}}}, - ) - with patch( "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery ): @@ -349,33 +347,18 @@ async def test_form_user_discovery_manual_and_auto_password_fetch_but_cannot_con assert result2["errors"] is None assert result2["step_id"] == "manual" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) - await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result3["errors"] is None - with patch( - "homeassistant.components.roomba.config_flow.RoombaFactory.create_roomba", - return_value=mocked_roomba, - ), patch( - "homeassistant.components.roomba.config_flow.RoombaPassword", - _mocked_getpassword, - ), patch( - "homeassistant.components.roomba.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {}, + "homeassistant.components.roomba.config_flow.RoombaDiscovery", + _mocked_no_devices_found_discovery, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_IP}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result4["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result4["reason"] == "cannot_connect" - assert len(mock_setup_entry.mock_calls) == 0 + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "cannot_connect" async def test_form_user_discovery_no_devices_found_and_auto_password_fetch(hass): @@ -400,10 +383,13 @@ async def test_form_user_discovery_no_devices_found_and_auto_password_fetch(hass assert result["errors"] is None assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] is None @@ -425,7 +411,7 @@ async def test_form_user_discovery_no_devices_found_and_auto_password_fetch(hass await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == "myroomba" + assert result3["title"] == "robot_name" assert result3["result"].unique_id == "BLID" assert result3["data"] == { CONF_BLID: "BLID", @@ -459,10 +445,13 @@ async def test_form_user_discovery_no_devices_found_and_password_fetch_fails(has assert result["errors"] is None assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] is None @@ -528,10 +517,13 @@ async def test_form_user_discovery_not_devices_found_and_password_fetch_fails_an assert result["errors"] is None assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] is None @@ -717,10 +709,13 @@ async def test_dhcp_discovery_falls_back_to_manual(hass, discovery_data): assert result2["errors"] is None assert result2["step_id"] == "manual" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM assert result3["errors"] is None @@ -742,7 +737,7 @@ async def test_dhcp_discovery_falls_back_to_manual(hass, discovery_data): await hass.async_block_till_done() assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"] == "myroomba" + assert result4["title"] == "robot_name" assert result4["result"].unique_id == "BLID" assert result4["data"] == { CONF_BLID: "BLID", @@ -779,10 +774,13 @@ async def test_dhcp_discovery_no_devices_falls_back_to_manual(hass, discovery_da assert result["errors"] is None assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, - ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] is None @@ -804,7 +802,7 @@ async def test_dhcp_discovery_no_devices_falls_back_to_manual(hass, discovery_da await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == "myroomba" + assert result3["title"] == "robot_name" assert result3["result"].unique_id == "BLID" assert result3["data"] == { CONF_BLID: "BLID", diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 6070daeb8af..9190b033f44 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -15,16 +15,25 @@ from homeassistant.const import ( SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, +) +from homeassistant.core import ( + Context, + CoreState, + HomeAssistant, + State, + callback, + split_entity_id, ) -from homeassistant.core import Context, callback, split_entity_id from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import template from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import bind_hass from homeassistant.setup import async_setup_component, setup_component +import homeassistant.util.dt as dt_util -from tests.common import async_mock_service, get_test_home_assistant +from tests.common import async_mock_service, get_test_home_assistant, mock_restore_cache from tests.components.logbook.test_init import MockLazyEventPartialState ENTITY_ID = "script.test" @@ -84,6 +93,7 @@ class TestScriptComponent(unittest.TestCase): def test_passing_variables(self): """Test different ways of passing in variables.""" + mock_restore_cache(self.hass, ()) calls = [] context = Context() @@ -796,3 +806,39 @@ async def test_script_this_var_always(hass, caplog): # Verify this available to all templates assert mock_calls[0].data.get("this_template") == "script.script1" assert "Error rendering variables" not in caplog.text + + +async def test_script_restore_last_triggered(hass: HomeAssistant) -> None: + """Test if last triggered is restored on start.""" + time = dt_util.utcnow() + mock_restore_cache( + hass, + ( + State("script.no_last_triggered", STATE_OFF), + State("script.last_triggered", STATE_OFF, {"last_triggered": time}), + ), + ) + hass.state = CoreState.starting + + assert await async_setup_component( + hass, + "script", + { + "script": { + "no_last_triggered": { + "sequence": [{"delay": {"seconds": 5}}], + }, + "last_triggered": { + "sequence": [{"delay": {"seconds": 5}}], + }, + }, + }, + ) + + state = hass.states.get("script.no_last_triggered") + assert state + assert state.attributes["last_triggered"] is None + + state = hass.states.get("script.last_triggered") + assert state + assert state.attributes["last_triggered"] == time diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py index 188099164c2..21745694d38 100644 --- a/tests/components/select/test_init.py +++ b/tests/components/select/test_init.py @@ -1,6 +1,18 @@ """The tests for the Select component.""" -from homeassistant.components.select import SelectEntity +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.select import ATTR_OPTIONS, DOMAIN, SelectEntity +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + CONF_PLATFORM, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component class MockSelectEntity(SelectEntity): @@ -26,3 +38,75 @@ async def test_select(hass: HomeAssistant) -> None: select._attr_current_option = "option_four" assert select.current_option == "option_four" assert select.state is None + + select.hass = hass + + with pytest.raises(NotImplementedError): + await select.async_select_option("option_one") + + select.select_option = MagicMock() + await select.async_select_option("option_one") + + assert select.select_option.called + assert select.select_option.call_args[0][0] == "option_one" + + assert select.capability_attributes[ATTR_OPTIONS] == [ + "option_one", + "option_two", + "option_three", + ] + + +async def test_custom_integration_and_validation(hass, enable_custom_integrations): + """Test we can only select valid options.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get("select.select_1").state == "option 1" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "option 2", ATTR_ENTITY_ID: "select.select_1"}, + blocking=True, + ) + + hass.states.async_set("select.select_1", "option 2") + await hass.async_block_till_done() + assert hass.states.get("select.select_1").state == "option 2" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "option invalid", ATTR_ENTITY_ID: "select.select_1"}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get("select.select_1").state == "option 2" + + assert hass.states.get("select.select_2").state == STATE_UNKNOWN + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "option invalid", ATTR_ENTITY_ID: "select.select_2"}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get("select.select_2").state == STATE_UNKNOWN + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "option 3", ATTR_ENTITY_ID: "select.select_2"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("select.select_2").state == "option 3" diff --git a/tests/components/sense/test_config_flow.py b/tests/components/sense/test_config_flow.py index 55348cca838..a56422dcb84 100644 --- a/tests/components/sense/test_config_flow.py +++ b/tests/components/sense/test_config_flow.py @@ -72,3 +72,22 @@ async def test_form_cannot_connect(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "sense_energy.ASyncSenseable.authenticate", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"timeout": "6", "email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index ce35e2506a9..5ef99b6c669 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -86,7 +86,7 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat if device_class != "none" ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 13 + assert len(triggers) == 23 assert triggers == expected_triggers diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py new file mode 100644 index 00000000000..7463cc6755a --- /dev/null +++ b/tests/components/sensor/test_init.py @@ -0,0 +1,61 @@ +"""The test for sensor device automation.""" +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + + +async def test_deprecated_temperature_conversion( + hass, caplog, enable_custom_integrations +): + """Test warning on deprecated temperature conversion.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", native_value="0.0", native_unit_of_measurement=TEMP_FAHRENHEIT + ) + + entity0 = platform.ENTITIES["0"] + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.state == "-17.8" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert ( + "Entity sensor.test () " + "with device_class None reports a temperature in °F which will be converted to " + "°C. Temperature conversion for entities without correct device_class is " + "deprecated and will be removed from Home Assistant Core 2022.3. Please update " + "your configuration if device_class is manually configured, otherwise report it " + "to the custom component author." + ) in caplog.text + + +async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): + """Test warning on deprecated last reset.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", state_class="measurement", last_reset=dt_util.utc_from_timestamp(0) + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + 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." + ) in caplog.text + + +async def test_deprecated_unit_of_measurement(hass, caplog, enable_custom_integrations): + """Test warning on deprecated unit_of_measurement.""" + SensorEntityDescription("catsensor", unit_of_measurement="cats") + assert ( + "tests.components.sensor.test_init is setting 'unit_of_measurement' on an " + "instance of SensorEntityDescription" + ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 58614e86a0e..115473c23de 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -10,6 +10,7 @@ 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.statistics import ( + get_metadata, list_statistic_ids, statistics_during_period, ) @@ -39,6 +40,11 @@ TEMPERATURE_SENSOR_ATTRIBUTES = { "state_class": "measurement", "unit_of_measurement": "°C", } +GAS_SENSOR_ATTRIBUTES = { + "device_class": "gas", + "state_class": "measurement", + "unit_of_measurement": "m³", +} @pytest.mark.parametrize( @@ -102,24 +108,34 @@ def test_compile_hourly_statistics( @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes): """Test compiling hourly statistics for unsupported sensor.""" - attributes = dict(attributes) zero = dt_util.utcnow() hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) four, states = record_states(hass, zero, "sensor.test1", attributes) - if "unit_of_measurement" in attributes: - attributes["unit_of_measurement"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test2", attributes) - states = {**states, **_states} - attributes.pop("unit_of_measurement") - _, _states = record_states(hass, zero, "sensor.test3", attributes) - states = {**states, **_states} - attributes["state_class"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test4", attributes) + + attributes_tmp = dict(attributes) + attributes_tmp["unit_of_measurement"] = "invalid" + _, _states = record_states(hass, zero, "sensor.test2", attributes_tmp) states = {**states, **_states} - attributes.pop("state_class") - _, _states = record_states(hass, zero, "sensor.test5", attributes) + attributes_tmp.pop("unit_of_measurement") + _, _states = record_states(hass, zero, "sensor.test3", attributes_tmp) + states = {**states, **_states} + + attributes_tmp = dict(attributes) + attributes_tmp["state_class"] = "invalid" + _, _states = record_states(hass, zero, "sensor.test4", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("state_class") + _, _states = record_states(hass, zero, "sensor.test5", attributes_tmp) + states = {**states, **_states} + + attributes_tmp = dict(attributes) + attributes_tmp["device_class"] = "invalid" + _, _states = record_states(hass, zero, "sensor.test6", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("device_class") + _, _states = record_states(hass, zero, "sensor.test7", attributes_tmp) states = {**states, **_states} hist = history.get_significant_states(hass, zero, four) @@ -129,7 +145,9 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ - {"statistic_id": "sensor.test1", "unit_of_measurement": "°C"} + {"statistic_id": "sensor.test1", "unit_of_measurement": "°C"}, + {"statistic_id": "sensor.test6", "unit_of_measurement": "°C"}, + {"statistic_id": "sensor.test7", "unit_of_measurement": "°C"}, ] stats = statistics_during_period(hass, zero) assert stats == { @@ -144,22 +162,49 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "state": None, "sum": None, } - ] + ], + "sensor.test6": [ + { + "statistic_id": "sensor.test6", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(16.440677966101696), + "min": approx(10.0), + "max": approx(30.0), + "last_reset": None, + "state": None, + "sum": None, + } + ], + "sensor.test7": [ + { + "statistic_id": "sensor.test7", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(16.440677966101696), + "min": approx(10.0), + "max": approx(30.0), + "last_reset": None, + "state": None, + "sum": None, + } + ], } 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), ("energy", "Wh", "kWh", 1 / 1000), - ("monetary", "€", "€", 1), + ("monetary", "EUR", "EUR", 1), ("monetary", "SEK", "SEK", 1), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), ], ) -def test_compile_hourly_energy_statistics( - hass_recorder, caplog, device_class, unit, native_unit, factor +def test_compile_hourly_sum_statistics_amount( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() @@ -168,13 +213,13 @@ def test_compile_hourly_energy_statistics( setup_component(hass, "sensor", {}) attributes = { "device_class": device_class, - "state_class": "measurement", + "state_class": state_class, "unit_of_measurement": unit, "last_reset": None, } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - four, eight, states = record_energy_states( + four, eight, states = record_meter_states( hass, zero, "sensor.test1", attributes, seq ) hist = history.get_significant_states( @@ -213,7 +258,7 @@ def test_compile_hourly_energy_statistics( "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[5]), - "sum": approx(factor * 10.0), + "sum": approx(factor * 40.0), }, { "statistic_id": "sensor.test1", @@ -223,7 +268,251 @@ def test_compile_hourly_energy_statistics( "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(factor * seq[8]), - "sum": approx(factor * 40.0), + "sum": approx(factor * 70.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + assert "Detected new cycle for sensor.test1, last_reset set to" in caplog.text + assert "Compiling initial sum statistics for sensor.test1" in caplog.text + assert "Detected new cycle for sensor.test1, value dropped" not in caplog.text + + +@pytest.mark.parametrize("state_class", ["measurement"]) +@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_amount_reset_every_state_change( + 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, 10] + # Make sure the sequence has consecutive equal states + assert seq[1] == seq[2] == seq[3] + + states = {"sensor.test1": []} + one = zero + for i in range(len(seq)): + one = one + timedelta(minutes=1) + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", 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) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(one), + "state": approx(factor * seq[7]), + "sum": approx(factor * (sum(seq) - seq[0])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("gas", "m³", "m³", 1), + ("gas", "ft³", "m³", 0.0283168466), + ], +) +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() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total_increasing", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] + + four, eight, states = record_meter_states( + hass, zero, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + 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) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "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(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 50.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 80.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + assert "Detected new cycle for sensor.test1, last_reset set to" not in caplog.text + assert "Compiling initial sum statistics for sensor.test1" in caplog.text + assert "Detected new cycle for sensor.test1, value dropped" in caplog.text + + +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [("energy", "kWh", "kWh", 1)], +) +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() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": "total_increasing", + "unit_of_measurement": unit, + } + seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] + + four, eight, states = record_meter_states( + hass, zero, "sensor.test1", attributes, seq + ) + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + 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" + ) not in caplog.text + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + 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" + ) 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) + assert stats == { + "sensor.test1": [ + { + "last_reset": None, + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "last_reset": None, + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "state": approx(factor * seq[5]), + "sum": approx(factor * 30.0), + }, + { + "last_reset": None, + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "state": approx(factor * seq[8]), + "sum": approx(factor * 60.0), }, ] } @@ -254,14 +543,14 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - four, eight, states = record_energy_states( + four, eight, states = record_meter_states( hass, zero, "sensor.test1", sns1_attr, seq1 ) - _, _, _states = record_energy_states(hass, zero, "sensor.test2", sns2_attr, seq2) + _, _, _states = record_meter_states(hass, zero, "sensor.test2", sns2_attr, seq2) states = {**states, **_states} - _, _, _states = record_energy_states(hass, zero, "sensor.test3", sns3_attr, seq3) + _, _, _states = record_meter_states(hass, zero, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_energy_states(hass, zero, "sensor.test4", sns4_attr, seq4) + _, _, _states = record_meter_states(hass, zero, "sensor.test4", sns4_attr, seq4) states = {**states, **_states} hist = history.get_significant_states( @@ -300,7 +589,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(40.0), }, { "statistic_id": "sensor.test1", @@ -310,7 +599,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(70.0), }, ] } @@ -336,14 +625,14 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - four, eight, states = record_energy_states( + four, eight, states = record_meter_states( hass, zero, "sensor.test1", sns1_attr, seq1 ) - _, _, _states = record_energy_states(hass, zero, "sensor.test2", sns2_attr, seq2) + _, _, _states = record_meter_states(hass, zero, "sensor.test2", sns2_attr, seq2) states = {**states, **_states} - _, _, _states = record_energy_states(hass, zero, "sensor.test3", sns3_attr, seq3) + _, _, _states = record_meter_states(hass, zero, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_energy_states(hass, zero, "sensor.test4", sns4_attr, seq4) + _, _, _states = record_meter_states(hass, zero, "sensor.test4", sns4_attr, seq4) states = {**states, **_states} hist = history.get_significant_states( hass, zero - timedelta.resolution, eight + timedelta.resolution @@ -383,7 +672,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(40.0), - "sum": approx(10.0), + "sum": approx(40.0), }, { "statistic_id": "sensor.test1", @@ -393,7 +682,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(70.0), - "sum": approx(40.0), + "sum": approx(70.0), }, ], "sensor.test2": [ @@ -415,7 +704,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(45.0), - "sum": approx(-95.0), + "sum": approx(-65.0), }, { "statistic_id": "sensor.test2", @@ -425,7 +714,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(75.0), - "sum": approx(-65.0), + "sum": approx(-35.0), }, ], "sensor.test3": [ @@ -447,7 +736,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(50.0 / 1000), - "sum": approx(30.0 / 1000), + "sum": approx(60.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -457,7 +746,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), "state": approx(90.0 / 1000), - "sum": approx(70.0 / 1000), + "sum": approx(100.0 / 1000), }, ], } @@ -632,6 +921,8 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): ("humidity", None, None, "mean"), ("monetary", "USD", "USD", "sum"), ("monetary", "None", "None", "sum"), + ("gas", "m³", "m³", "sum"), + ("gas", "ft³", "m³", "sum"), ("pressure", "Pa", "Pa", "mean"), ("pressure", "hPa", "Pa", "mean"), ("pressure", "mbar", "Pa", "mean"), @@ -694,10 +985,314 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): hass.states.set("sensor.test6", 0, attributes=attributes) +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + (None, "%", "%", 16.440677, 10, 30), + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_units_1( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change from one hour to the next.""" + 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) + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, zero + timedelta(hours=1), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, zero + timedelta(hours=2), "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) + 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) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + assert ( + "The unit of sensor.test1 (cats) does not match the unit of already compiled " + f"statistics ({native_unit})" 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) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "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,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + (None, "%", "%", 16.440677, 10, 30), + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_units_2( + 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() + 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) + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, zero + timedelta(hours=1), "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)) + 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 + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": "cats"} + ] + stats = statistics_during_period(hass, zero) + assert stats == {} + + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + (None, "%", "%", 16.440677, 10, 30), + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ], +) +def test_compile_hourly_statistics_changing_units_3( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): + """Test compiling hourly statistics where units change from one hour to the next.""" + 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) + four, _states = record_states( + hass, zero + timedelta(hours=1), "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 + ) + 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) + 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) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + 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 + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "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,mean,min,max", + [ + (None, None, None, 16.440677, 10, 30), + ], +) +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() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes_1 = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + attributes_2 = { + "device_class": device_class, + "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) + 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") + assert metadata == { + "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 + ) + 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(hours=1)) + 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") + assert metadata == { + "has_mean": False, + "has_sum": True, + "statistic_id": "sensor.test1", + "unit_of_measurement": None, + } + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": approx(30.0), + "sum": approx(30.0), + }, + ] + } + + assert "Error while processing event StatisticsTask" not in caplog.text + + def record_states(hass, zero, entity_id, attributes): """Record some test states. - We inject a bunch of state updates for temperature sensors. + We inject a bunch of state updates for measurement sensors. """ attributes = dict(attributes) @@ -725,10 +1320,10 @@ def record_states(hass, zero, entity_id, attributes): return four, states -def record_energy_states(hass, zero, entity_id, _attributes, seq): +def record_meter_states(hass, zero, entity_id, _attributes, seq): """Record some test states. - We inject a bunch of state updates for energy sensors. + We inject a bunch of state updates for meter sensors. """ def set_state(entity_id, state, **kwargs): @@ -785,6 +1380,28 @@ def record_energy_states(hass, zero, entity_id, _attributes, seq): return four, eight, states +def record_meter_state(hass, zero, entity_id, _attributes, seq): + """Record test state. + + We inject a state update for meter sensor. + """ + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + 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)) + + return states + + def record_states_partially_unavailable(hass, zero, entity_id, attributes): """Record some test states. diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 48482787f4d..65fddec894e 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -1,12 +1,17 @@ """Test shopping list component.""" -from homeassistant.components.shopping_list.const import DOMAIN +from homeassistant.components.shopping_list.const import ( + DOMAIN, + SERVICE_ADD_ITEM, + SERVICE_CLEAR_COMPLETED_ITEMS, + SERVICE_COMPLETE_ITEM, +) from homeassistant.components.websocket_api.const import ( ERR_INVALID_FORMAT, ERR_NOT_FOUND, TYPE_RESULT, ) -from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.const import ATTR_NAME, HTTP_NOT_FOUND from homeassistant.helpers import intent @@ -53,6 +58,29 @@ async def test_update_list(hass, sl_setup): assert cheese["complete"] is False +async def test_clear_completed_items(hass, sl_setup): + """Test clear completed list items.""" + await intent.async_handle( + hass, + "test", + "HassShoppingListAddItem", + {"item": {"value": "beer"}}, + ) + + await intent.async_handle( + hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}} + ) + + assert len(hass.data[DOMAIN].items) == 2 + + # Update a single attribute, other attributes shouldn't change + await hass.data[DOMAIN].async_update_list({"complete": True}) + + await hass.data[DOMAIN].async_clear_completed() + + assert len(hass.data[DOMAIN].items) == 0 + + async def test_recent_items_intent(hass, sl_setup): """Test recent items.""" @@ -471,3 +499,46 @@ async def test_ws_reorder_items_failure(hass, hass_ws_client, sl_setup): msg = await client.receive_json() assert msg["success"] is False assert msg["error"]["code"] == ERR_INVALID_FORMAT + + +async def test_add_item_service(hass, sl_setup): + """Test adding shopping_list item service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(hass.data[DOMAIN].items) == 1 + + +async def test_clear_completed_items_service(hass, sl_setup): + """Test clearing completed shopping_list items service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(hass.data[DOMAIN].items) == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_COMPLETE_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(hass.data[DOMAIN].items) == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_COMPLETED_ITEMS, + {}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(hass.data[DOMAIN].items) == 0 diff --git a/tests/components/shopping_list/test_intent.py b/tests/components/shopping_list/test_intent.py index d0bcb1d837c..a03353e510e 100644 --- a/tests/components/shopping_list/test_intent.py +++ b/tests/components/shopping_list/test_intent.py @@ -20,3 +20,12 @@ async def test_recent_items_intent(hass, sl_setup): response.speech["plain"]["speech"] == "These are the top 3 items on your shopping list: soda, wine, beer" ) + + +async def test_recent_items_intent_no_items(hass, sl_setup): + """Test recent items.""" + response = await intent.async_handle(hass, "test", "HassShoppingListLastItems") + + assert ( + response.speech["plain"]["speech"] == "There are no items on your shopping list" + ) diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index 729990ceaeb..e46fbbf8d5e 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -48,9 +48,32 @@ async def test_no_available_tones(hass): process_turn_on_params(siren, {"tone": "test"}) -async def test_missing_tones(hass): - """Test ValueError when setting a tone that is missing from available_tones.""" +async def test_available_tones_list(hass): + """Test that valid tones from tone list will get passed in.""" + siren = MockSirenEntity(SUPPORT_TONES, ["a", "b"]) + siren.hass = hass + assert process_turn_on_params(siren, {"tone": "a"}) == {"tone": "a"} + + +async def test_available_tones_dict(hass): + """Test that valid tones from available_tones dict will get passed in.""" + siren = MockSirenEntity(SUPPORT_TONES, {1: "a", 2: "b"}) + siren.hass = hass + assert process_turn_on_params(siren, {"tone": "a"}) == {"tone": 1} + assert process_turn_on_params(siren, {"tone": 1}) == {"tone": 1} + + +async def test_missing_tones_list(hass): + """Test ValueError when setting a tone that is missing from available_tones list.""" siren = MockSirenEntity(SUPPORT_TONES, ["a", "b"]) siren.hass = hass with pytest.raises(ValueError): process_turn_on_params(siren, {"tone": "test"}) + + +async def test_missing_tones_dict(hass): + """Test ValueError when setting a tone that is missing from available_tones dict.""" + siren = MockSirenEntity(SUPPORT_TONES, {1: "a", 2: "b"}) + siren.hass = hass + with pytest.raises(ValueError): + process_turn_on_params(siren, {"tone": 3}) diff --git a/tests/components/sleepiq/test_sensor.py b/tests/components/sleepiq/test_sensor.py index c6a584802b9..7a7e47f03fa 100644 --- a/tests/components/sleepiq/test_sensor.py +++ b/tests/components/sleepiq/test_sensor.py @@ -21,15 +21,17 @@ async def test_setup(hass, requests_mock): assert len(devices) == 2 left_side = devices[1] + left_side.hass = hass assert left_side.name == "SleepNumber ILE Test1 SleepNumber" assert left_side.state == 40 right_side = devices[0] + right_side.hass = hass assert right_side.name == "SleepNumber ILE Test2 SleepNumber" assert right_side.state == 80 -async def test_setup_sigle(hass, requests_mock): +async def test_setup_single(hass, requests_mock): """Test for successfully setting up the SleepIQ platform.""" mock_responses(requests_mock, single=True) @@ -41,5 +43,6 @@ async def test_setup_sigle(hass, requests_mock): assert len(devices) == 1 right_side = devices[0] + right_side.hass = hass assert right_side.name == "SleepNumber ILE Test1 SleepNumber" assert right_side.state == 40 diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 70103c3a837..f36f05616d6 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -168,7 +168,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "1412.002" entry = entity_registry.async_get("sensor.refrigerator_energy") assert entry - assert entry.unique_id == f"{device.device_id}.energy" + assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label @@ -180,7 +180,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "109" entry = entity_registry.async_get("sensor.refrigerator_power") assert entry - assert entry.unique_id == f"{device.device_id}.power" + assert entry.unique_id == f"{device.device_id}.power_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label @@ -202,7 +202,7 @@ async def test_power_consumption_sensor(hass, device_factory): assert state.state == "unknown" entry = entity_registry.async_get("sensor.vacuum_energy") assert entry - assert entry.unique_id == f"{device.device_id}.energy" + assert entry.unique_id == f"{device.device_id}.energy_meter" entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.name == device.label diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py index c1896061f79..87b38e52742 100644 --- a/tests/components/sonarr/test_config_flow.py +++ b/tests/components/sonarr/test_config_flow.py @@ -100,14 +100,16 @@ async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test the manual reauth flow from start to finish.""" - entry = await setup_integration( - hass, aioclient_mock, skip_entry_setup=True, unique_id="any" - ) + entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True) assert entry result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: SOURCE_REAUTH, "unique_id": entry.unique_id}, + context={ + CONF_SOURCE: SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, data=entry.data, ) diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index 11db05d2994..d0378731c28 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -26,9 +26,7 @@ async def test_speedtestdotnet_sensors( assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - for sensor_type in SENSOR_TYPES: - sensor = hass.states.get( - f"sensor.{DEFAULT_NAME}_{SENSOR_TYPES[sensor_type][0]}" - ) + for description in SENSOR_TYPES: + sensor = hass.states.get(f"sensor.{DEFAULT_NAME}_{description.name}") assert sensor - assert sensor.state == MOCK_STATES[sensor_type] + assert sensor.state == MOCK_STATES[description.key] diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index e740ea671cd..9460e2235be 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -178,7 +178,7 @@ async def test_discovery(hass): ): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DISCOVERY}, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={CONF_HOST: HOST, CONF_PORT: PORT, "uuid": UUID}, ) assert result["type"] == RESULT_TYPE_FORM @@ -190,7 +190,7 @@ async def test_discovery_no_uuid(hass): with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DISCOVERY}, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={CONF_HOST: HOST, CONF_PORT: PORT}, ) assert result["type"] == RESULT_TYPE_FORM @@ -199,9 +199,8 @@ async def test_discovery_no_uuid(hass): async def test_dhcp_discovery(hass): """Test we can process discovery from dhcp.""" - with patch( - "pysqueezebox.Server.async_query", - return_value={"uuid": UUID}, + with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch( + "homeassistant.components.squeezebox.config_flow.async_discover", mock_discover ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -216,9 +215,12 @@ async def test_dhcp_discovery(hass): assert result["step_id"] == "edit" -async def test_dhcp_discovery_no_connection(hass): - """Test we can process discovery from dhcp without connecting to squeezebox server.""" - with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized): +async def test_dhcp_discovery_no_server_found(hass): + """Test we can handle dhcp discovery when no server is found.""" + with patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_failed_discover, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -229,7 +231,25 @@ async def test_dhcp_discovery_no_connection(hass): }, ) assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "edit" + assert result["step_id"] == "user" + + +async def test_dhcp_discovery_existing_player(hass): + """Test that we properly ignore known players during dhcp discover.""" + with patch( + "homeassistant.helpers.entity_registry.EntityRegistry.async_get_entity_id", + return_value="test_entity", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={ + IP_ADDRESS: "1.1.1.1", + MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", + HOSTNAME: "any", + }, + ) + assert result["type"] == RESULT_TYPE_ABORT async def test_import(hass): diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 069dc9eb64f..3e830b7fc93 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -82,6 +82,7 @@ async def test_srp_entity(hass): """Test the SrpEntity.""" fake_coordinator = MagicMock(data=1.99999999999) srp_entity = SrpEntity(fake_coordinator) + srp_entity.hass = hass assert srp_entity is not None assert srp_entity.name == f"{DEFAULT_NAME} {SENSOR_NAME}" @@ -104,6 +105,7 @@ async def test_srp_entity_no_data(hass): """Test the SrpEntity.""" fake_coordinator = MagicMock(data=False) srp_entity = SrpEntity(fake_coordinator) + srp_entity.hass = hass assert srp_entity.extra_state_attributes is None @@ -111,6 +113,7 @@ async def test_srp_entity_no_coord_data(hass): """Test the SrpEntity.""" fake_coordinator = MagicMock(data=False) srp_entity = SrpEntity(fake_coordinator) + srp_entity.hass = hass assert srp_entity.usage is None @@ -124,6 +127,7 @@ async def test_srp_entity_async_update(hass): MagicMock.__await__ = lambda x: async_magic().__await__() fake_coordinator = MagicMock(data=False) srp_entity = SrpEntity(fake_coordinator) + srp_entity.hass = hass await srp_entity.async_update() assert fake_coordinator.async_request_refresh.called diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 34ca1b7228e..43b7fd98cd0 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -29,7 +29,13 @@ def _patched_ssdp_listener(info, *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 @@ -287,7 +293,10 @@ async def test_invalid_characters(hass, aioclient_mock): @patch("homeassistant.components.ssdp.SSDPListener.async_start") @patch("homeassistant.components.ssdp.SSDPListener.async_search") -async def test_start_stop_scanner(async_start_mock, async_search_mock, hass): +@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: {}}) @@ -295,15 +304,18 @@ async def test_start_stop_scanner(async_start_mock, async_search_mock, hass): 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 == 2 + 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 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 == 2 + assert async_start_mock.call_count == 1 assert async_search_mock.call_count == 2 + assert async_stop_mock.call_count == 1 async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog): @@ -787,7 +799,6 @@ async def test_async_detect_interfaces_setting_empty_route(hass): assert argset == { (IPv6Address("2001:db8::"), None), - (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), (IPv4Address("192.168.1.5"), None), } @@ -802,12 +813,12 @@ async def test_bind_failure_skips_adapter(hass, caplog): ] } create_args = [] - did_search = 0 + search_args = [] @callback - def _callback(*_): - nonlocal did_search - did_search += 1 + def _callback(*args): + nonlocal search_args + search_args.append(args) pass def _generate_failing_ssdp_listener(*args, **kwargs): @@ -844,11 +855,198 @@ async def test_bind_failure_skips_adapter(hass, caplog): assert argset == { (IPv6Address("2001:db8::"), None), - (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), (IPv4Address("192.168.1.5"), None), } assert "Failed to setup listener for" in caplog.text async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert did_search == 2 + assert set(search_args) == { + (), + ( + ( + "255.255.255.255", + 1900, + ), + ), + } + + +async def test_ipv4_does_additional_search_for_sonos(hass, caplog): + """Test that only ipv4 does an additional search for Sonos.""" + mock_get_ssdp = { + "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(*_): + import pprint + + pprint.pprint(mock_ssdp_response) + 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"] + ) diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index ffbeb44d79e..e62a190d7be 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -15,6 +15,7 @@ failure modes or corner cases like how out of order packets are handled. import fractions import io +import logging import math import threading from unittest.mock import patch @@ -52,7 +53,7 @@ SEGMENTS_PER_PACKET = PACKET_DURATION / SEGMENT_DURATION TIMEOUT = 15 -class FakePyAvStream: +class FakeAvInputStream: """A fake pyav Stream.""" def __init__(self, name, rate): @@ -66,9 +67,13 @@ class FakePyAvStream: self.codec = FakeCodec() + def __str__(self) -> str: + """Return a stream name for debugging.""" + return f"FakePyAvStream<{self.name}, {self.time_base}>" -VIDEO_STREAM = FakePyAvStream(VIDEO_STREAM_FORMAT, VIDEO_FRAME_RATE) -AUDIO_STREAM = FakePyAvStream(AUDIO_STREAM_FORMAT, AUDIO_SAMPLE_RATE) + +VIDEO_STREAM = FakeAvInputStream(VIDEO_STREAM_FORMAT, VIDEO_FRAME_RATE) +AUDIO_STREAM = FakeAvInputStream(AUDIO_STREAM_FORMAT, AUDIO_SAMPLE_RATE) class PacketSequence: @@ -110,6 +115,9 @@ class PacketSequence: is_keyframe = not (self.packet - 1) % (VIDEO_FRAME_RATE * KEYFRAME_INTERVAL) size = 3 + def __str__(self) -> str: + return f"FakePacket" + return FakePacket() @@ -154,7 +162,7 @@ class FakePyAvBuffer: def add_stream(self, template=None): """Create an output buffer that captures packets for test to examine.""" - class FakeStream: + class FakeAvOutputStream: def __init__(self, capture_packets): self.capture_packets = capture_packets @@ -162,11 +170,15 @@ class FakePyAvBuffer: return def mux(self, packet): + logging.debug("Muxed packet: %s", packet) self.capture_packets.append(packet) + def __str__(self) -> str: + return f"FakeAvOutputStream<{template.name}>" + if template.name == AUDIO_STREAM_FORMAT: - return FakeStream(self.audio_packets) - return FakeStream(self.video_packets) + return FakeAvOutputStream(self.audio_packets) + return FakeAvOutputStream(self.video_packets) def mux(self, packet): """Capture a packet for tests to examine.""" @@ -217,7 +229,7 @@ async def async_decode_stream(hass, packets, py_av=None): if not py_av: py_av = MockPyAv() - py_av.container.packets = packets + py_av.container.packets = iter(packets) # Can't be rewound with patch("av.open", new=py_av.open), patch( "homeassistant.components.stream.core.StreamOutput.put", @@ -273,7 +285,7 @@ async def test_skip_out_of_order_packet(hass): assert not packets[out_of_order_index].is_keyframe packets[out_of_order_index].dts = -9090 - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check sequence numbers @@ -309,7 +321,7 @@ async def test_discard_old_packets(hass): # Packets after this one are considered out of order packets[OUT_OF_ORDER_PACKET_INDEX - 1].dts = 9090 - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check number of segments @@ -331,7 +343,7 @@ async def test_packet_overflow(hass): # Packet is so far out of order, exceeds max gap and looks like overflow packets[OUT_OF_ORDER_PACKET_INDEX].dts = -9000000 - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check number of segments @@ -355,7 +367,7 @@ async def test_skip_initial_bad_packets(hass): for i in range(0, num_bad_packets): packets[i].dts = None - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check sequence numbers @@ -385,7 +397,7 @@ async def test_too_many_initial_bad_packets_fails(hass): for i in range(0, num_bad_packets): packets[i].dts = None - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments assert len(segments) == 0 assert len(decoded_stream.video_packets) == 0 @@ -405,7 +417,7 @@ async def test_skip_missing_dts(hass): continue packets[i].dts = None - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check sequence numbers @@ -426,7 +438,7 @@ async def test_too_many_bad_packets(hass): for i in range(bad_packet_start, bad_packet_start + num_bad_packets): packets[i].dts = None - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) complete_segments = decoded_stream.complete_segments assert len(complete_segments) == int((bad_packet_start - 1) * SEGMENTS_PER_PACKET) assert len(decoded_stream.video_packets) == bad_packet_start @@ -454,7 +466,7 @@ async def test_audio_packets_not_found(hass): num_packets = PACKETS_TO_WAIT_FOR_AUDIO + 1 packets = PacketSequence(num_packets) # Contains only video packets - decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) complete_segments = decoded_stream.complete_segments assert len(complete_segments) == int((num_packets - 1) * SEGMENTS_PER_PACKET) assert len(decoded_stream.video_packets) == num_packets @@ -474,8 +486,10 @@ async def test_adts_aac_audio(hass): packets[1][0] = 255 packets[1][1] = 241 - decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) assert len(decoded_stream.audio_packets) == 0 + # All decoded video packets are still preserved + assert len(decoded_stream.video_packets) == num_packets - 1 async def test_audio_is_first_packet(hass): @@ -493,7 +507,7 @@ async def test_audio_is_first_packet(hass): packets[2].dts = int(packets[3].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) packets[2].pts = int(packets[3].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) - decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) complete_segments = decoded_stream.complete_segments # The audio packets are segmented with the video packets assert len(complete_segments) == int((num_packets - 2 - 1) * SEGMENTS_PER_PACKET) @@ -511,7 +525,7 @@ async def test_audio_packets_found(hass): packets[1].dts = int(packets[0].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) packets[1].pts = int(packets[0].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) - decoded_stream = await async_decode_stream(hass, iter(packets), py_av=py_av) + decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) complete_segments = decoded_stream.complete_segments # The audio packet above is buffered with the video packet assert len(complete_segments) == int((num_packets - 1 - 1) * SEGMENTS_PER_PACKET) @@ -529,7 +543,7 @@ async def test_pts_out_of_order(hass): packets[i].pts = packets[i - 1].pts - 1 packets[i].is_keyframe = False - decoded_stream = await async_decode_stream(hass, iter(packets)) + decoded_stream = await async_decode_stream(hass, packets) segments = decoded_stream.segments complete_segments = decoded_stream.complete_segments # Check number of segments diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 9f8d821e74b..2ccfb26d3ef 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -2,9 +2,6 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.components.device_automation import ( - _async_get_device_automations as async_get_device_automations, -) from homeassistant.components.switch import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -12,6 +9,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, + async_get_device_automations, async_mock_service, mock_device_registry, mock_registry, diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 5cd7a77d911..96603c39bcd 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -55,79 +55,24 @@ FIXTURE_ZEROCONF_BAD = { }, } -FIXTURE_OS = { - "platform": "linux", - "distro": "Ubuntu", - "release": "20.10", - "codename": "Groovy Gorilla", - "kernel": "5.8.0-44-generic", - "arch": "x64", - "hostname": "test-bridge", - "fqdn": "test-bridge.local", - "codepage": "UTF-8", - "logofile": "ubuntu", - "serial": "abcdefghijklmnopqrstuvwxyz", - "build": "", - "servicepack": "", - "uefi": True, - "users": [], -} - -FIXTURE_NETWORK = { - "connections": [], - "gatewayDefault": "192.168.1.1", - "interfaceDefault": "wlp2s0", - "interfaces": { - "wlp2s0": { - "iface": "wlp2s0", - "ifaceName": "wlp2s0", - "ip4": "1.1.1.1", - "mac": FIXTURE_MAC_ADDRESS, - }, - }, - "stats": {}, -} - -FIXTURE_SYSTEM = { - "baseboard": { - "manufacturer": "System manufacturer", - "model": "Model", - "version": "Rev X.0x", - "serial": "1234567", - "assetTag": "", - "memMax": 134217728, - "memSlots": 4, - }, - "bios": { - "vendor": "System vendor", - "version": "12345", - "releaseDate": "2019-11-13", - "revision": "", - }, - "chassis": { - "manufacturer": "Default string", - "model": "", - "type": "Desktop", - "version": "Default string", - "serial": "Default string", - "assetTag": "", - "sku": "", - }, - "system": { - "manufacturer": "System manufacturer", - "model": "System Product Name", - "version": "System Version", - "serial": "System Serial Number", - "uuid": "abc123-def456", - "sku": "SKU", - "virtual": False, - }, - "uuid": { - "os": FIXTURE_UUID, - "hardware": "abc123-def456", - "macs": [FIXTURE_MAC_ADDRESS], +FIXTURE_INFORMATION = { + "address": "http://test-bridge:9170", + "apiPort": 9170, + "fqdn": "test-bridge", + "host": "test-bridge", + "ip": "1.1.1.1", + "mac": FIXTURE_MAC_ADDRESS, + "updates": { + "available": False, + "newer": False, + "url": "https://github.com/timmo001/system-bridge/releases/tag/v2.3.2", + "version": {"current": "2.3.2", "new": "2.3.2"}, }, + "uuid": FIXTURE_UUID, + "version": "2.3.2", + "websocketAddress": "ws://test-bridge:9172", + "websocketPort": 9172, } @@ -151,9 +96,11 @@ async def test_user_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", json=FIXTURE_OS) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", json=FIXTURE_NETWORK) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", json=FIXTURE_SYSTEM) + aioclient_mock.get( + f"{FIXTURE_BASE_URL}/information", + headers={"Content-Type": "application/json"}, + json=FIXTURE_INFORMATION, + ) with patch( "homeassistant.components.system_bridge.async_setup_entry", @@ -181,9 +128,9 @@ async def test_form_invalid_auth( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=BridgeAuthenticationException) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=BridgeAuthenticationException) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=BridgeAuthenticationException) + aioclient_mock.get( + f"{FIXTURE_BASE_URL}/information", exc=BridgeAuthenticationException + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT @@ -206,9 +153,7 @@ async def test_form_cannot_connect( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=ClientConnectionError) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=ClientConnectionError) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=ClientConnectionError) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/information", exc=ClientConnectionError) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT @@ -220,7 +165,7 @@ async def test_form_cannot_connect( assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unknow_error( +async def test_form_unknown_error( hass, aiohttp_client, aioclient_mock, current_request_with_host ) -> None: """Test we handle unknown error.""" @@ -232,10 +177,9 @@ async def test_form_unknow_error( assert result["errors"] is None with patch( - "homeassistant.components.system_bridge.config_flow.Bridge.async_get_os", + "homeassistant.components.system_bridge.config_flow.Bridge.async_get_information", side_effect=Exception("Boom"), ): - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT ) @@ -257,9 +201,9 @@ async def test_reauth_authorization_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=BridgeAuthenticationException) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=BridgeAuthenticationException) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=BridgeAuthenticationException) + aioclient_mock.get( + f"{FIXTURE_BASE_URL}/information", exc=BridgeAuthenticationException + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT @@ -282,9 +226,7 @@ async def test_reauth_connection_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=ClientConnectionError) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=ClientConnectionError) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=ClientConnectionError) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/information", exc=ClientConnectionError) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT @@ -312,9 +254,11 @@ async def test_reauth_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", json=FIXTURE_OS) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", json=FIXTURE_NETWORK) - aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", json=FIXTURE_SYSTEM) + aioclient_mock.get( + f"{FIXTURE_BASE_URL}/information", + headers={"Content-Type": "application/json"}, + json=FIXTURE_INFORMATION, + ) with patch( "homeassistant.components.system_bridge.async_setup_entry", @@ -345,9 +289,11 @@ async def test_zeroconf_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert not result["errors"] - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/os", json=FIXTURE_OS) - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/network", json=FIXTURE_NETWORK) - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/system", json=FIXTURE_SYSTEM) + aioclient_mock.get( + f"{FIXTURE_ZEROCONF_BASE_URL}/information", + headers={"Content-Type": "application/json"}, + json=FIXTURE_INFORMATION, + ) with patch( "homeassistant.components.system_bridge.async_setup_entry", @@ -378,11 +324,9 @@ async def test_zeroconf_cannot_connect( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert not result["errors"] - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/os", exc=ClientConnectionError) aioclient_mock.get( - f"{FIXTURE_ZEROCONF_BASE_URL}/network", exc=ClientConnectionError + f"{FIXTURE_ZEROCONF_BASE_URL}/information", exc=ClientConnectionError ) - aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/system", exc=ClientConnectionError) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT diff --git a/tests/components/tasmota/test_config_flow.py b/tests/components/tasmota/test_config_flow.py index 767d6b9cfcf..7d6d0628de1 100644 --- a/tests/components/tasmota/test_config_flow.py +++ b/tests/components/tasmota/test_config_flow.py @@ -1,6 +1,5 @@ """Test config flow.""" from homeassistant import config_entries -from homeassistant.components.mqtt.models import ReceiveMessage from tests.common import MockConfigEntry @@ -19,21 +18,85 @@ async def test_mqtt_abort_if_existing_entry(hass, mqtt_mock): async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): """Check MQTT flow aborts if discovery topic is invalid.""" - discovery_info = ReceiveMessage( - "", "", 0, False, subscribed_topic="custom_prefix/##" - ) + discovery_info = { + "topic": "tasmota/discovery/DC4F220848A2/bla", + "payload": ( + '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' + 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' + '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' + '"TOGGLE","HOLD"],"sw":"9.4.0.4","t":"tasmota_0848A2","ft":"%topic%/%prefix%/",' + '"tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],' + '"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],' + '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' + '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' + ), + "qos": 0, + "retain": False, + "subscribed_topic": "tasmota/discovery/#", + "timestamp": None, + } result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) assert result["type"] == "abort" assert result["reason"] == "invalid_discovery_info" + discovery_info = { + "topic": "tasmota/discovery/DC4F220848A2/config", + "payload": "", + "qos": 0, + "retain": False, + "subscribed_topic": "tasmota/discovery/#", + "timestamp": None, + } + result = await hass.config_entries.flow.async_init( + "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + discovery_info = { + "topic": "tasmota/discovery/DC4F220848A2/config", + "payload": ( + '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' + 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' + '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' + '"TOGGLE","HOLD"],"sw":"9.4.0.4","t":"tasmota_0848A2","ft":"%topic%/%prefix%/",' + '"tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],' + '"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],' + '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' + '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' + ), + "qos": 0, + "retain": False, + "subscribed_topic": "tasmota/discovery/#", + "timestamp": None, + } + result = await hass.config_entries.flow.async_init( + "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] == "form" + async def test_mqtt_setup(hass, mqtt_mock) -> None: """Test we can finish a config flow through MQTT with custom prefix.""" - discovery_info = ReceiveMessage( - "", "", 0, False, subscribed_topic="custom_prefix/123/#" - ) + discovery_info = { + "topic": "tasmota/discovery/DC4F220848A2/config", + "payload": ( + '{"ip":"192.168.0.136","dn":"Tasmota","fn":["Tasmota",null,null,null,null,' + 'null,null,null],"hn":"tasmota_0848A2","mac":"DC4F220848A2","md":"Sonoff Basic",' + '"ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON",' + '"TOGGLE","HOLD"],"sw":"9.4.0.4","t":"tasmota_0848A2","ft":"%topic%/%prefix%/",' + '"tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],' + '"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],' + '"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":1,"117":0},' + '"lk":1,"lt_st":0,"sho":[0,0,0,0],"ver":1}' + ), + "qos": 0, + "retain": False, + "subscribed_topic": "tasmota/discovery/#", + "timestamp": None, + } result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) @@ -42,9 +105,7 @@ async def test_mqtt_setup(hass, mqtt_mock) -> None: result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["result"].data == { - "discovery_prefix": "custom_prefix/123", - } + assert result["result"].data == {"discovery_prefix": "tasmota/discovery"} async def test_user_setup(hass, mqtt_mock): diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index a64c5e9c5e4..202a6a5386b 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -9,6 +9,7 @@ from hatasmota.utils import ( get_topic_tele_will, ) import pytest +from voluptuous import MultipleInvalid from homeassistant.components import fan from homeassistant.components.tasmota.const import DEFAULT_PREFIX @@ -51,46 +52,38 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF - assert state.attributes["speed"] is None assert state.attributes["percentage"] is None - assert state.attributes["speed_list"] == ["off", "low", "medium", "high"] assert state.attributes["supported_features"] == fan.SUPPORT_SET_SPEED assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":1}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON - assert state.attributes["speed"] == "low" assert state.attributes["percentage"] == 33 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":2}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON - assert state.attributes["speed"] == "medium" assert state.attributes["percentage"] == 66 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":3}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON - assert state.attributes["speed"] == "high" assert state.attributes["percentage"] == 100 async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":0}') state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF - assert state.attributes["speed"] == "off" assert state.attributes["percentage"] == 0 async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"FanSpeed":1}') state = hass.states.get("fan.tasmota") assert state.state == STATE_ON - assert state.attributes["speed"] == "low" assert state.attributes["percentage"] == 33 async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"FanSpeed":0}') state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF - assert state.attributes["speed"] == "off" assert state.attributes["percentage"] == 0 @@ -132,34 +125,6 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): ) mqtt_mock.async_publish.reset_mock() - # Set speed and verify MQTT message is sent - await common.async_set_speed(hass, "fan.tasmota", fan.SPEED_OFF) - mqtt_mock.async_publish.assert_called_once_with( - "tasmota_49A3BC/cmnd/FanSpeed", "0", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - # Set speed and verify MQTT message is sent - await common.async_set_speed(hass, "fan.tasmota", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "tasmota_49A3BC/cmnd/FanSpeed", "1", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - # Set speed and verify MQTT message is sent - await common.async_set_speed(hass, "fan.tasmota", fan.SPEED_MEDIUM) - mqtt_mock.async_publish.assert_called_once_with( - "tasmota_49A3BC/cmnd/FanSpeed", "2", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - # Set speed and verify MQTT message is sent - await common.async_set_speed(hass, "fan.tasmota", fan.SPEED_HIGH) - mqtt_mock.async_publish.assert_called_once_with( - "tasmota_49A3BC/cmnd/FanSpeed", "3", 0, False - ) - mqtt_mock.async_publish.reset_mock() - # Set speed percentage and verify MQTT message is sent await common.async_set_percentage(hass, "fan.tasmota", 0) mqtt_mock.async_publish.assert_called_once_with( @@ -188,7 +153,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): ) -async def test_invalid_fan_speed(hass, mqtt_mock, setup_tasmota): +async def test_invalid_fan_speed_percentage(hass, mqtt_mock, setup_tasmota): """Test the sending MQTT commands.""" config = copy.deepcopy(DEFAULT_CONFIG) config["if"] = 1 @@ -209,9 +174,9 @@ async def test_invalid_fan_speed(hass, mqtt_mock, setup_tasmota): mqtt_mock.async_publish.reset_mock() # Set an unsupported speed and verify MQTT message is not sent - with pytest.raises(ValueError) as excinfo: - await common.async_set_speed(hass, "fan.tasmota", "no_such_speed") - assert "no_such_speed" in str(excinfo.value) + with pytest.raises(MultipleInvalid) as excinfo: + await common.async_set_percentage(hass, "fan.tasmota", 101) + assert "value must be at most 100" in str(excinfo.value) mqtt_mock.async_publish.assert_not_called() diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index fc1e7fd624b..adb73dcf334 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -255,6 +255,9 @@ async def test_indexed_sensor_state_via_mqtt2(hass, mqtt_mock, setup_tasmota): state = hass.states.get("sensor.tasmota_energy_total") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) + assert ( + state.attributes[sensor.ATTR_STATE_CLASS] == sensor.STATE_CLASS_TOTAL_INCREASING + ) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") state = hass.states.get("sensor.tasmota_energy_total") @@ -269,7 +272,6 @@ async def test_indexed_sensor_state_via_mqtt2(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("sensor.tasmota_energy_total") assert state.state == "1.2" - assert state.attributes["last_reset"] == "2018-11-23T15:33:47+00:00" # Test polled state update async_fire_mqtt_message( @@ -279,7 +281,6 @@ async def test_indexed_sensor_state_via_mqtt2(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("sensor.tasmota_energy_total") assert state.state == "5.6" - assert state.attributes["last_reset"] == "2018-11-23T16:33:47+00:00" async def test_bad_indexed_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 0848200b35d..e2168d0925e 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,2 +1,27 @@ """template conftest.""" -from tests.components.light.conftest import mock_light_profiles # noqa: F401 +import pytest + +from homeassistant.setup import async_setup_component + +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") + + +@pytest.fixture +async def start_ha(hass, count, domain, config, caplog): + """Do setup of integration.""" + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py new file mode 100644 index 00000000000..f297307fd0e --- /dev/null +++ b/tests/components/template/test_number.py @@ -0,0 +1,336 @@ +"""The tests for the Template number platform.""" +import pytest + +from homeassistant import setup +from homeassistant.components.input_number import ( + ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE as NUMBER_ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE, +) +from homeassistant.const import CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import Context +from homeassistant.helpers.entity_registry import async_get + +from tests.common import ( + assert_setup_component, + async_capture_events, + async_mock_service, +) + +_TEST_NUMBER = "number.template_number" +# Represent for number's value +_VALUE_INPUT_NUMBER = "input_number.value" +# Represent for number's minimum +_MINIMUM_INPUT_NUMBER = "input_number.minimum" +# Represent for number's maximum +_MAXIMUM_INPUT_NUMBER = "input_number.maximum" +# Represent for number's step +_STEP_INPUT_NUMBER = "input_number.step" + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_missing_optional_config(hass, calls): + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "state": "{{ 4 }}", + "set_value": {"service": "script.set_value"}, + "step": "{{ 1 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, 4, 1, 0.0, 100.0) + + +async def test_missing_required_keys(hass, calls): + """Test: missing required fields will fail.""" + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "set_value": {"service": "script.set_value"}, + } + } + }, + ) + + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "state": "{{ 4 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_all_optional_config(hass, calls): + """Test: including all optional templates is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "state": "{{ 4 }}", + "set_value": {"service": "script.set_value"}, + "min": "{{ 3 }}", + "max": "{{ 5 }}", + "step": "{{ 1 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, 4, 1, 3, 5) + + +async def test_templates_with_entities(hass, calls): + """Test tempalates with values from other entities.""" + with assert_setup_component(4, "input_number"): + assert await setup.async_setup_component( + hass, + "input_number", + { + "input_number": { + "value": { + "min": 0.0, + "max": 100.0, + "name": "Value", + "step": 1.0, + "mode": "slider", + }, + "step": { + "min": 0.0, + "max": 100.0, + "name": "Step", + "step": 1.0, + "mode": "slider", + }, + "minimum": { + "min": 0.0, + "max": 100.0, + "name": "Minimum", + "step": 1.0, + "mode": "slider", + }, + "maximum": { + "min": 0.0, + "max": 100.0, + "name": "Maximum", + "step": 1.0, + "mode": "slider", + }, + } + }, + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "b", + "number": { + "state": f"{{{{ states('{_VALUE_INPUT_NUMBER}') }}}}", + "step": f"{{{{ states('{_STEP_INPUT_NUMBER}') }}}}", + "min": f"{{{{ states('{_MINIMUM_INPUT_NUMBER}') }}}}", + "max": f"{{{{ states('{_MAXIMUM_INPUT_NUMBER}') }}}}", + "set_value": { + "service": "input_number.set_value", + "data_template": { + "entity_id": _VALUE_INPUT_NUMBER, + "value": "{{ value }}", + }, + }, + "optimistic": True, + "unique_id": "a", + }, + } + }, + ) + + hass.states.async_set(_VALUE_INPUT_NUMBER, 4) + hass.states.async_set(_STEP_INPUT_NUMBER, 1) + hass.states.async_set(_MINIMUM_INPUT_NUMBER, 3) + hass.states.async_set(_MAXIMUM_INPUT_NUMBER, 5) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + ent_reg = async_get(hass) + entry = ent_reg.async_get(_TEST_NUMBER) + assert entry + assert entry.unique_id == "b-a" + + _verify(hass, 4, 1, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 5}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 1, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _STEP_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _MINIMUM_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 2, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _MAXIMUM_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 6}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 2, 6) + + await hass.services.async_call( + NUMBER_DOMAIN, + NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _TEST_NUMBER, NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + _verify(hass, 2, 2, 2, 6) + + +async def test_trigger_number(hass): + """Test trigger based template number.""" + events = async_capture_events(hass, "test_number_event") + assert await setup.async_setup_component( + hass, + "template", + { + "template": [ + {"invalid": "config"}, + # Config after invalid should still be set up + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "number": [ + { + "name": "Hello Name", + "unique_id": "hello_name-id", + "state": "{{ trigger.event.data.beers_drank }}", + "min": "{{ trigger.event.data.min_beers }}", + "max": "{{ trigger.event.data.max_beers }}", + "step": "{{ trigger.event.data.step }}", + "set_value": {"event": "test_number_event"}, + "optimistic": True, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("number.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes["min"] == 0.0 + assert state.attributes["max"] == 100.0 + assert state.attributes["step"] == 1.0 + + context = Context() + hass.bus.async_fire( + "test_event", + {"beers_drank": 3, "min_beers": 1.0, "max_beers": 5.0, "step": 0.5}, + context=context, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.hello_name") + assert state is not None + assert state.state == "3.0" + assert state.attributes["min"] == 1.0 + assert state.attributes["max"] == 5.0 + assert state.attributes["step"] == 0.5 + + await hass.services.async_call( + NUMBER_DOMAIN, + NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: "number.hello_name", NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + assert len(events) == 1 + assert events[0].event_type == "test_number_event" + + +def _verify( + hass, + expected_value, + expected_step, + expected_minimum, + expected_maximum, +): + """Verify number's state.""" + state = hass.states.get(_TEST_NUMBER) + attributes = state.attributes + assert state.state == str(float(expected_value)) + assert attributes.get(ATTR_STEP) == float(expected_step) + assert attributes.get(ATTR_MAX) == float(expected_maximum) + assert attributes.get(ATTR_MIN) == float(expected_minimum) diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py new file mode 100644 index 00000000000..eb94a9284f4 --- /dev/null +++ b/tests/components/template/test_select.py @@ -0,0 +1,258 @@ +"""The tests for the Template select platform.""" +import pytest + +from homeassistant import setup +from homeassistant.components.input_select import ( + ATTR_OPTION as INPUT_SELECT_ATTR_OPTION, + ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS, + DOMAIN as INPUT_SELECT_DOMAIN, + SERVICE_SELECT_OPTION as INPUT_SELECT_SERVICE_SELECT_OPTION, + SERVICE_SET_OPTIONS, +) +from homeassistant.components.select.const import ( + ATTR_OPTION as SELECT_ATTR_OPTION, + ATTR_OPTIONS as SELECT_ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, +) +from homeassistant.const import CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import Context +from homeassistant.helpers.entity_registry import async_get + +from tests.common import ( + assert_setup_component, + async_capture_events, + async_mock_service, +) + +_TEST_SELECT = "select.template_select" +# Represent for select's current_option +_OPTION_INPUT_SELECT = "input_select.option" + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_missing_optional_config(hass, calls): + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, "a", ["a", "b"]) + + +async def test_missing_required_keys(hass, calls): + """Test: missing required fields will fail.""" + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": { + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + with assert_setup_component(0, "select"): + assert await setup.async_setup_component( + hass, + "select", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + } + } + }, + ) + + with assert_setup_component(0, "select"): + assert await setup.async_setup_component( + hass, + "select", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_templates_with_entities(hass, calls): + """Test tempalates with values from other entities.""" + with assert_setup_component(1, "input_select"): + assert await setup.async_setup_component( + hass, + "input_select", + { + "input_select": { + "option": { + "options": ["a", "b"], + "initial": "a", + "name": "Option", + }, + } + }, + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "b", + "select": { + "state": f"{{{{ states('{_OPTION_INPUT_SELECT}') }}}}", + "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", + "select_option": { + "service": "input_select.select_option", + "data_template": { + "entity_id": _OPTION_INPUT_SELECT, + "option": "{{ option }}", + }, + }, + "optimistic": True, + "unique_id": "a", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + ent_reg = async_get(hass) + entry = ent_reg.async_get(_TEST_SELECT) + assert entry + assert entry.unique_id == "b-a" + + _verify(hass, "a", ["a", "b"]) + + await hass.services.async_call( + INPUT_SELECT_DOMAIN, + INPUT_SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, "b", ["a", "b"]) + + await hass.services.async_call( + INPUT_SELECT_DOMAIN, + SERVICE_SET_OPTIONS, + { + CONF_ENTITY_ID: _OPTION_INPUT_SELECT, + INPUT_SELECT_ATTR_OPTIONS: ["a", "b", "c"], + }, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, "a", ["a", "b", "c"]) + + await hass.services.async_call( + SELECT_DOMAIN, + SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: _TEST_SELECT, SELECT_ATTR_OPTION: "c"}, + blocking=True, + ) + _verify(hass, "c", ["a", "b", "c"]) + + +async def test_trigger_select(hass): + """Test trigger based template select.""" + events = async_capture_events(hass, "test_number_event") + assert await setup.async_setup_component( + hass, + "template", + { + "template": [ + {"invalid": "config"}, + # Config after invalid should still be set up + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "select": [ + { + "name": "Hello Name", + "unique_id": "hello_name-id", + "state": "{{ trigger.event.data.beer }}", + "options": "{{ trigger.event.data.beers }}", + "select_option": {"event": "test_number_event"}, + "optimistic": True, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("select.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire( + "test_event", {"beer": "duff", "beers": ["duff", "alamo"]}, context=context + ) + await hass.async_block_till_done() + + state = hass.states.get("select.hello_name") + assert state is not None + assert state.state == "duff" + assert state.attributes["options"] == ["duff", "alamo"] + + await hass.services.async_call( + SELECT_DOMAIN, + SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: "select.hello_name", SELECT_ATTR_OPTION: "alamo"}, + blocking=True, + ) + assert len(events) == 1 + assert events[0].event_type == "test_number_event" + + +def _verify(hass, expected_current_option, expected_options): + """Verify select's state.""" + state = hass.states.get(_TEST_SELECT) + attributes = state.attributes + assert state.state == str(expected_current_option) + assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index e18f8ecc059..6e0252845d1 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -12,7 +12,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN -from tests.common import assert_setup_component, async_mock_service +from tests.common import assert_setup_component from tests.components.vacuum import common _TEST_VACUUM = "vacuum.test_vacuum" @@ -23,19 +23,13 @@ _FAN_SPEED_INPUT_SELECT = "input_select.fan_speed" _BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" -@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, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", +@pytest.mark.parametrize("count,domain", [(1, "vacuum")]) +@pytest.mark.parametrize( + "parm1,parm2,config", + [ + ( + STATE_UNKNOWN, + None, { "vacuum": { "platform": "template", @@ -44,66 +38,90 @@ async def test_missing_optional_config(hass, calls): }, } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_missing_start_config(hass, calls): - """Test: missing 'start' will fail.""" - with assert_setup_component(0, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", + ), + ( + STATE_CLEANING, + 100, { "vacuum": { "platform": "template", - "vacuums": {"test_vacuum": {"value_template": "{{ 'on' }}"}}, + "vacuums": { + "test_vacuum": { + "value_template": "{{ 'cleaning' }}", + "battery_level_template": "{{ 100 }}", + "start": {"service": "script.vacuum_start"}, + } + }, } }, - ) - - 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: invalid config structure will fail.""" - with assert_setup_component(0, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", + ), + ( + STATE_UNKNOWN, + None, { - "platform": "template", - "vacuums": { - "test_vacuum": {"start": {"service": "script.vacuum_start"}} - }, + "vacuum": { + "platform": "template", + "vacuums": { + "test_vacuum": { + "value_template": "{{ 'abc' }}", + "battery_level_template": "{{ 101 }}", + "start": {"service": "script.vacuum_start"}, + } + }, + } }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] + ), + ( + STATE_UNKNOWN, + None, + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_vacuum": { + "value_template": "{{ this_function_does_not_exist() }}", + "battery_level_template": "{{ this_function_does_not_exist() }}", + "fan_speed_template": "{{ this_function_does_not_exist() }}", + "start": {"service": "script.vacuum_start"}, + } + }, + } + }, + ), + ], +) +async def test_valid_configs(hass, count, parm1, parm2, start_ha): + """Test: configs.""" + assert len(hass.states.async_all()) == count + _verify(hass, parm1, parm2) -# End of configuration tests # +@pytest.mark.parametrize("count,domain", [(0, "vacuum")]) +@pytest.mark.parametrize( + "config", + [ + { + "vacuum": { + "platform": "template", + "vacuums": {"test_vacuum": {"value_template": "{{ 'on' }}"}}, + } + }, + { + "platform": "template", + "vacuums": {"test_vacuum": {"start": {"service": "script.vacuum_start"}}}, + }, + ], +) +async def test_invalid_configs(hass, count, start_ha): + """Test: configs.""" + assert len(hass.states.async_all()) == count -# Template tests # -async def test_templates_with_entities(hass, calls): - """Test templates with values from other entities.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, "vacuum", { "vacuum": { @@ -118,125 +136,41 @@ async def test_templates_with_entities(hass, calls): } }, ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + ], +) +async def test_templates_with_entities(hass, start_ha): + """Test templates with values from other entities.""" _verify(hass, STATE_UNKNOWN, None) hass.states.async_set(_STATE_INPUT_SELECT, STATE_CLEANING) hass.states.async_set(_BATTERY_LEVEL_INPUT_NUMBER, 100) await hass.async_block_till_done() - _verify(hass, STATE_CLEANING, 100) -async def test_templates_with_valid_values(hass, calls): - """Test templates with valid values.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, "vacuum", { "vacuum": { "platform": "template", "vacuums": { - "test_vacuum": { - "value_template": "{{ 'cleaning' }}", - "battery_level_template": "{{ 100 }}", + "test_template_vacuum": { + "availability_template": "{{ is_state('availability_state.state', 'on') }}", "start": {"service": "script.vacuum_start"}, } }, } }, ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_CLEANING, 100) - - -async def test_templates_invalid_values(hass, calls): - """Test templates with invalid values.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ 'abc' }}", - "battery_level_template": "{{ 101 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_invalid_templates(hass, calls): - """Test invalid templates.""" - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ this_function_does_not_exist() }}", - "battery_level_template": "{{ this_function_does_not_exist() }}", - "fan_speed_template": "{{ this_function_does_not_exist() }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_available_template_with_entities(hass, calls): + ], +) +async def test_available_template_with_entities(hass, start_ha): """Test availability templates with values from other entities.""" - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "availability_template": "{{ is_state('availability_state.state', 'on') }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - # When template returns true.. hass.states.async_set("availability_state.state", STATE_ON) await hass.async_block_till_done() @@ -252,57 +186,60 @@ async def test_available_template_with_entities(hass, calls): assert hass.states.get("vacuum.test_template_vacuum").state == STATE_UNAVAILABLE -async def test_invalid_availability_template_keeps_component_available(hass, caplog): +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_template_vacuum": { + "availability_template": "{{ x - 12 }}", + "start": {"service": "script.vacuum_start"}, + } + }, + } + }, + ) + ], +) +async def test_invalid_availability_template_keeps_component_available( + hass, caplog, start_ha +): """Test that an invalid availability keeps the device available.""" - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "availability_template": "{{ x - 12 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.get("vacuum.test_template_vacuum") != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog.text + text = str([x.getMessage() for x in caplog.get_records("setup")]) + assert ("UndefinedError: \\'x\\' is undefined") in text -async def test_attribute_templates(hass, calls): +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_template_vacuum": { + "value_template": "{{ 'cleaning' }}", + "start": {"service": "script.vacuum_start"}, + "attribute_templates": { + "test_attribute": "It {{ states.sensor.test_state.state }}." + }, + } + }, + } + }, + ) + ], +) +async def test_attribute_templates(hass, start_ha): """Test attribute_templates template.""" - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "value_template": "{{ 'cleaning' }}", - "start": {"service": "script.vacuum_start"}, - "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() - state = hass.states.get("vacuum.test_template_vacuum") assert state.attributes["test_attribute"] == "It ." @@ -315,41 +252,101 @@ async def test_attribute_templates(hass, calls): assert state.attributes["test_attribute"] == "It Works." -async def test_invalid_attribute_template(hass, caplog): +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "invalid_template": { + "value_template": "{{ states('input_select.state') }}", + "start": {"service": "script.vacuum_start"}, + "attribute_templates": { + "test_attribute": "{{ this_function_does_not_exist() }}" + }, + } + }, + } + }, + ) + ], +) +async def test_invalid_attribute_template(hass, caplog, start_ha): """Test that errors are logged if rendering template fails.""" - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "invalid_template": { - "value_template": "{{ states('input_select.state') }}", - "start": {"service": "script.vacuum_start"}, - "attribute_templates": { - "test_attribute": "{{ this_function_does_not_exist() }}" - }, - } - }, - } - }, - ) - await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 - await hass.async_start() + text = str([x.getMessage() for x in caplog.get_records("setup")]) + assert "test_attribute" in text + assert "TemplateError" in text + + +@pytest.mark.parametrize( + "count,domain,config", + [ + ( + 1, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_template_vacuum_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "start": {"service": "script.vacuum_start"}, + }, + "test_template_vacuum_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "start": {"service": "script.vacuum_start"}, + }, + }, + } + }, + ), + ], +) +async def test_unique_id(hass, start_ha): + """Test unique_id option only creates one vacuum per id.""" + assert len(hass.states.async_all()) == 1 + + +async def test_unused_services(hass): + """Test calling unused services should not crash.""" + await _register_basic_vacuum(hass) + + # Pause vacuum + await common.async_pause(hass, _TEST_VACUUM) await hass.async_block_till_done() - assert "test_attribute" in caplog.text - assert "TemplateError" in caplog.text + # Stop vacuum + await common.async_stop(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + # Return vacuum to base + await common.async_return_to_base(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + # Spot cleaning + await common.async_clean_spot(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + # Locate vacuum + await common.async_locate(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + # Set fan's speed + await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN, None) -# End of template tests # - - -# Function tests # -async def test_state_services(hass, calls): +async def test_state_services(hass): """Test state services.""" await _register_components(hass) @@ -386,38 +383,7 @@ async def test_state_services(hass, calls): _verify(hass, STATE_RETURNING, None) -async def test_unused_services(hass, calls): - """Test calling unused services should not crash.""" - await _register_basic_vacuum(hass) - - # Pause vacuum - await common.async_pause(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Stop vacuum - await common.async_stop(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Return vacuum to base - await common.async_return_to_base(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Spot cleaning - await common.async_clean_spot(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Locate vacuum - await common.async_locate(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # Set fan's speed - await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN, None) - - -async def test_clean_spot_service(hass, calls): +async def test_clean_spot_service(hass): """Test clean spot service.""" await _register_components(hass) @@ -429,7 +395,7 @@ async def test_clean_spot_service(hass, calls): assert hass.states.get(_SPOT_CLEANING_INPUT_BOOLEAN).state == STATE_ON -async def test_locate_service(hass, calls): +async def test_locate_service(hass): """Test locate service.""" await _register_components(hass) @@ -441,7 +407,7 @@ async def test_locate_service(hass, calls): assert hass.states.get(_LOCATING_INPUT_BOOLEAN).state == STATE_ON -async def test_set_fan_speed(hass, calls): +async def test_set_fan_speed(hass): """Test set valid fan speed.""" await _register_components(hass) @@ -460,7 +426,7 @@ async def test_set_fan_speed(hass, calls): assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "medium" -async def test_set_invalid_fan_speed(hass, calls): +async def test_set_invalid_fan_speed(hass): """Test set invalid fan speed when fan has valid speed.""" await _register_components(hass) @@ -611,34 +577,3 @@ async def _register_components(hass): await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - - -async def test_unique_id(hass): - """Test unique_id option only creates one vacuum per id.""" - await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - "start": {"service": "script.vacuum_start"}, - }, - "test_template_vacuum_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - "start": {"service": "script.vacuum_start"}, - }, - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 1 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 6139fae9b11..d96d6846939 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,7 +1,6 @@ """Tests for the TP-Link component.""" from __future__ import annotations -from datetime import datetime import time from typing import Any from unittest.mock import MagicMock, patch @@ -223,11 +222,6 @@ async def test_platforms_are_initialized(hass: HomeAssistant): ), patch( "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False, - ), patch( - "homeassistant.components.tplink.get_time_offset", - return_value=( - datetime.now() - datetime.now().replace(hour=0, minute=0, second=0) - ), ): light = SmartBulb("123.123.123.123") @@ -418,12 +412,7 @@ async def test_unload(hass, platform): ), patch( f"homeassistant.components.tplink.{platform}.async_setup_entry", return_value=mock_coro(True), - ) as async_setup_entry, patch( - "homeassistant.components.tplink.get_time_offset", - return_value=( - datetime.now() - datetime.now().replace(hour=0, minute=0, second=0) - ), - ): + ) as async_setup_entry: config = { tplink.DOMAIN: { platform: [{CONF_HOST: "123.123.123.123"}], diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index c9b07529ea4..1854e714902 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -525,7 +525,7 @@ async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> Non assert state.state == "on" assert state.attributes["brightness"] == 51 assert state.attributes["color_temp"] == 222 - assert "hs_color" not in state.attributes + assert "hs_color" in state.attributes assert light_state["on_off"] == 1 await hass.services.async_call( @@ -582,7 +582,7 @@ async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> Non assert state.state == "on" assert state.attributes["brightness"] == 168 assert state.attributes["color_temp"] == 156 - assert "hs_color" not in state.attributes + assert "hs_color" in state.attributes assert light_state["brightness"] == 66 assert light_state["hue"] == 77 assert light_state["saturation"] == 78 diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py new file mode 100644 index 00000000000..4e2f5e0ff09 --- /dev/null +++ b/tests/components/traccar/test_device_tracker.py @@ -0,0 +1,62 @@ +"""The tests for the Traccar device tracker platform.""" +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from homeassistant.components.device_tracker.const import DOMAIN +from homeassistant.components.traccar.device_tracker import ( + PLATFORM_SCHEMA as TRACCAR_PLATFORM_SCHEMA, +) +from homeassistant.const import ( + CONF_EVENT, + CONF_HOST, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_USERNAME, +) +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + + +async def test_import_events_catch_all(hass): + """Test importing all events and firing them in HA using their event types.""" + conf_dict = { + DOMAIN: TRACCAR_PLATFORM_SCHEMA( + { + CONF_PLATFORM: "traccar", + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "fake_pass", + CONF_EVENT: ["all_events"], + } + ) + } + + device = {"id": 1, "name": "abc123"} + api_mock = AsyncMock() + api_mock.devices = [device] + api_mock.get_events.return_value = [ + { + "deviceId": device["id"], + "type": "ignitionOn", + "serverTime": datetime.utcnow(), + "attributes": {}, + }, + { + "deviceId": device["id"], + "type": "ignitionOff", + "serverTime": datetime.utcnow(), + "attributes": {}, + }, + ] + + events_ignition_on = async_capture_events(hass, "traccar_ignition_on") + events_ignition_off = async_capture_events(hass, "traccar_ignition_off") + + with patch( + "homeassistant.components.traccar.device_tracker.API", return_value=api_mock + ): + assert await async_setup_component(hass, DOMAIN, conf_dict) + + assert len(events_ignition_on) == 1 + assert len(events_ignition_off) == 1 diff --git a/tests/components/tractive/__init__.py b/tests/components/tractive/__init__.py new file mode 100644 index 00000000000..dcde4b87436 --- /dev/null +++ b/tests/components/tractive/__init__.py @@ -0,0 +1 @@ +"""Tests for the tractive integration.""" diff --git a/tests/components/tractive/test_config_flow.py b/tests/components/tractive/test_config_flow.py new file mode 100644 index 00000000000..7ccfdc63a34 --- /dev/null +++ b/tests/components/tractive/test_config_flow.py @@ -0,0 +1,242 @@ +"""Test the tractive config flow.""" +from unittest.mock import patch + +import aiotractive + +from homeassistant import config_entries, setup +from homeassistant.components.tractive.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +USER_INPUT = { + "email": "test-email@example.com", + "password": "test-password", +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "aiotractive.api.API.user_id", return_value={"user_id": "user_id"} + ), patch( + "homeassistant.components.tractive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email@example.com" + assert result2["data"] == USER_INPUT + 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( + "aiotractive.api.API.user_id", + side_effect=aiotractive.exceptions.UnauthorizedError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_unknown_error(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( + "aiotractive.api.API.user_id", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["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="tractive", + data=USER_INPUT, + unique_id="USERID", + ) + first_entry.add_to_hass(hass) + + with patch("aiotractive.api.API.user_id", return_value="USERID"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_reauthentication(hass): + """Test Tractive reauthentication.""" + old_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + 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("aiotractive.api.API.user_id", return_value="USERID"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_reauthentication_failure(hass): + """Test Tractive reauthentication failure.""" + old_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + 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( + "aiotractive.api.API.user_id", + side_effect=aiotractive.exceptions.UnauthorizedError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + 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_unknown_failure(hass): + """Test Tractive reauthentication failure.""" + old_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + 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( + "aiotractive.api.API.user_id", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result["type"] == "form" + assert result2["errors"]["base"] == "unknown" + + +async def test_reauthentication_failure_no_existing_entry(hass): + """Test Tractive reauthentication with no existing entry.""" + old_entry = MockConfigEntry( + domain="tractive", + data=USER_INPUT, + 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("aiotractive.api.API.user_id", return_value="USERID_DIFFERENT"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_failed_existing" diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py index 8e11ab06f34..e8cc83a456c 100644 --- a/tests/components/tradfri/test_init.py +++ b/tests/components/tradfri/test_init.py @@ -1,81 +1,12 @@ """Tests for Tradfri setup.""" from unittest.mock import patch -from homeassistant import config_entries from homeassistant.components import tradfri from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_config_yaml_host_not_imported(hass): - """Test that we don't import a configured host.""" - MockConfigEntry(domain="tradfri", data={"host": "mock-host"}).add_to_hass(hass) - - with patch( - "homeassistant.components.tradfri.load_json", return_value={} - ), patch.object(hass.config_entries.flow, "async_init") as mock_init: - assert await async_setup_component( - hass, "tradfri", {"tradfri": {"host": "mock-host"}} - ) - await hass.async_block_till_done() - - assert len(mock_init.mock_calls) == 0 - - -async def test_config_yaml_host_imported(hass): - """Test that we import a configured host.""" - with patch("homeassistant.components.tradfri.load_json", return_value={}): - assert await async_setup_component( - hass, "tradfri", {"tradfri": {"host": "mock-host"}} - ) - await hass.async_block_till_done() - - progress = hass.config_entries.flow.async_progress() - assert len(progress) == 1 - assert progress[0]["handler"] == "tradfri" - assert progress[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - - -async def test_config_json_host_not_imported(hass): - """Test that we don't import a configured host.""" - MockConfigEntry(domain="tradfri", data={"host": "mock-host"}).add_to_hass(hass) - - with patch( - "homeassistant.components.tradfri.load_json", - return_value={"mock-host": {"key": "some-info"}}, - ), patch.object(hass.config_entries.flow, "async_init") as mock_init: - assert await async_setup_component(hass, "tradfri", {"tradfri": {}}) - await hass.async_block_till_done() - - assert len(mock_init.mock_calls) == 0 - - -async def test_config_json_host_imported( - hass, mock_gateway_info, mock_entry_setup, gateway_id -): - """Test that we import a configured host.""" - mock_gateway_info.side_effect = lambda hass, host, identity, key: { - "host": host, - "identity": identity, - "key": key, - "gateway_id": gateway_id, - } - - with patch( - "homeassistant.components.tradfri.load_json", - return_value={"mock-host": {"key": "some-info"}}, - ): - assert await async_setup_component(hass, "tradfri", {"tradfri": {}}) - await hass.async_block_till_done() - - config_entry = mock_entry_setup.mock_calls[0][1][1] - assert config_entry.domain == "tradfri" - assert config_entry.source == config_entries.SOURCE_IMPORT - assert config_entry.title == "mock-host" - - async def test_entry_setup_unload(hass, api_factory, gateway_id): """Test config entry setup and unload.""" entry = MockConfigEntry( diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 54617a61348..737d37052c2 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -998,7 +998,8 @@ class TestMediaPlayer(unittest.TestCase): assert len(self.mock_mp_2.service_calls["shuffle_set"]) == 1 asyncio.run_coroutine_threadsafe(ump.async_toggle(), self.hass.loop).result() - assert len(self.mock_mp_2.service_calls["toggle"]) == 1 + # Delegate to turn_off + assert len(self.mock_mp_2.service_calls["turn_off"]) == 2 def test_service_call_to_command(self): """Test service call to command.""" diff --git a/tests/components/upnp/common.py b/tests/components/upnp/common.py new file mode 100644 index 00000000000..4dd0fd4083d --- /dev/null +++ b/tests/components/upnp/common.py @@ -0,0 +1,23 @@ +"""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/mock_ssdp_scanner.py b/tests/components/upnp/mock_ssdp_scanner.py new file mode 100644 index 00000000000..39f9a801bb6 --- /dev/null +++ b/tests/components/upnp/mock_ssdp_scanner.py @@ -0,0 +1,49 @@ +"""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_device.py b/tests/components/upnp/mock_upnp_device.py similarity index 63% rename from tests/components/upnp/mock_device.py rename to tests/components/upnp/mock_upnp_device.py index 7161ae69598..42c9291f30f 100644 --- a/tests/components/upnp/mock_device.py +++ b/tests/components/upnp/mock_upnp_device.py @@ -1,7 +1,9 @@ """Mock device for testing purposes.""" from typing import Any, Mapping -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant.components.upnp.const import ( BYTES_RECEIVED, @@ -9,10 +11,15 @@ from homeassistant.components.upnp.const import ( 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.""" @@ -23,12 +30,13 @@ class MockDevice(Device): mock_device_updater = AsyncMock() super().__init__(igd_device, mock_device_updater) self._udn = udn - self.times_polled = 0 + 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("UDN") + return cls(TEST_UDN) @property def udn(self) -> str: @@ -62,7 +70,7 @@ class MockDevice(Device): async def async_get_traffic_data(self) -> Mapping[str, Any]: """Get traffic data.""" - self.times_polled += 1 + self.traffic_times_polled += 1 return { TIMESTAMP: dt.utcnow(), BYTES_RECEIVED: 0, @@ -70,3 +78,27 @@ class MockDevice(Device): 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 6e546be93f3..907fa709c84 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -1,8 +1,9 @@ """Test UPnP/IGD config flow.""" from datetime import timedelta -from unittest.mock import AsyncMock, Mock, patch -from urllib.parse import urlparse +from unittest.mock import patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp @@ -12,119 +13,91 @@ from homeassistant.components.upnp.const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, - DISCOVERY_HOSTNAME, - DISCOVERY_LOCATION, - DISCOVERY_NAME, - DISCOVERY_ST, - DISCOVERY_UDN, - DISCOVERY_UNIQUE_ID, - DISCOVERY_USN, DOMAIN, ) -from homeassistant.components.upnp.device import Device -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt -from .mock_device import MockDevice +from .common import ( + TEST_DISCOVERY, + TEST_FRIENDLY_NAME, + TEST_HOSTNAME, + TEST_LOCATION, + TEST_ST, + 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 -async def test_flow_ssdp_discovery(hass: HomeAssistant): +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") +async def test_flow_ssdp_discovery( + hass: HomeAssistant, +): """Test config flow: discovered + configured through ssdp.""" - udn = "uuid:device_1" - location = "http://dummy" - mock_device = MockDevice(udn) - ssdp_discoveries = [ - { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } - ] - discoveries = [ - { - DISCOVERY_LOCATION: location, - DISCOVERY_NAME: mock_device.name, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_UNIQUE_ID: mock_device.unique_id, - DISCOVERY_USN: mock_device.usn, - DISCOVERY_HOSTNAME: mock_device.hostname, - } - ] - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object( - ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) - ), patch.object( - Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) - ): - # Discovered via step ssdp. - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_SSDP_USN: mock_device.usn, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "ssdp_confirm" - - # Confirm via step ssdp_confirm. - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == mock_device.name - assert result["data"] == { - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, - } - - -async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): - """Test config flow: incomplete discovery through ssdp.""" - udn = "uuid:device_1" - location = "http://dummy" - mock_device = MockDevice(udn) + # 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, + context={"source": config_entries.SOURCE_SSDP}, + data=TEST_DISCOVERY, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "ssdp_confirm" + + # Confirm via step ssdp_confirm. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TEST_FRIENDLY_NAME + assert result["data"] == { + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, + } + + +@pytest.mark.usefixtures("mock_ssdp_scanner") +async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): + """Test config flow: incomplete discovery through ssdp.""" # Discovered via step ssdp. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data={ - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_SSDP_USN: mock_device.usn, - # ssdp.ATTR_UPNP_UDN: mock_device.udn, # Not provided. + ssdp.ATTR_SSDP_LOCATION: TEST_LOCATION, + ssdp.ATTR_SSDP_ST: TEST_ST, + ssdp.ATTR_SSDP_USN: TEST_USN, + # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "incomplete_discovery" +@pytest.mark.usefixtures("mock_ssdp_scanner") 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.""" - udn = "uuid:device_random_1" - location = "http://dummy" - mock_device = MockDevice(udn) - # Existing entry. config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONFIG_ENTRY_UDN: "uuid:device_random_2", - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_HOSTNAME: urlparse(location).hostname, + CONFIG_ENTRY_UDN: TEST_UDN + "2", + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) @@ -134,129 +107,78 @@ async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_SSDP_USN: mock_device.usn, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - }, + data=TEST_DISCOVERY, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "discovery_ignored" +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") async def test_flow_user(hass: HomeAssistant): """Test config flow: discovered + configured through user.""" - udn = "uuid:device_1" - location = "http://dummy" - mock_device = MockDevice(udn) - ssdp_discoveries = [ - { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } - ] - discoveries = [ - { - DISCOVERY_LOCATION: location, - DISCOVERY_NAME: mock_device.name, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_UNIQUE_ID: mock_device.unique_id, - DISCOVERY_USN: mock_device.usn, - DISCOVERY_HOSTNAME: mock_device.hostname, - } - ] + # 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 - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object( - ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) - ), patch.object( - Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) - ): - # Discovered via step user. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + # Discovered via step user. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" - # Confirmed via step user. - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"unique_id": mock_device.unique_id}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == mock_device.name - assert result["data"] == { - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, - } + # Confirmed via step user. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"unique_id": TEST_USN}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TEST_FRIENDLY_NAME + assert result["data"] == { + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, + } +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") async def test_flow_import(hass: HomeAssistant): - """Test config flow: discovered + configured through configuration.yaml.""" - udn = "uuid:device_1" - mock_device = MockDevice(udn) - location = "http://dummy" - ssdp_discoveries = [ - { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } - ] - discoveries = [ - { - DISCOVERY_LOCATION: location, - DISCOVERY_NAME: mock_device.name, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_UNIQUE_ID: mock_device.unique_id, - DISCOVERY_USN: mock_device.usn, - DISCOVERY_HOSTNAME: mock_device.hostname, - } - ] + """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 - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object( - ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) - ), patch.object( - Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) - ): - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == mock_device.name - assert result["data"] == { - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, - } + # Discovered via step import. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TEST_FRIENDLY_NAME + assert result["data"] == { + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, + } +@pytest.mark.usefixtures("mock_ssdp_scanner") async def test_flow_import_already_configured(hass: HomeAssistant): - """Test config flow: discovered, but already configured.""" - udn = "uuid:device_1" - mock_device = MockDevice(udn) - + """Test config flow: configured through configuration.yaml, but existing config entry.""" # Existing entry. config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) @@ -271,94 +193,93 @@ async def test_flow_import_already_configured(hass: HomeAssistant): assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_ssdp_scanner") async def test_flow_import_no_devices_found(hass: HomeAssistant): """Test config flow: no devices found, configured through configuration.yaml.""" - ssdp_discoveries = [] - with patch.object( - ssdp, "async_get_discovery_info_by_st", Mock(return_value=ssdp_discoveries) + # 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 ): - # Discovered via step import. 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") 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. - udn = "uuid:device_1" - location = "http://192.168.1.1/desc.xml" - mock_device = MockDevice(udn) - ssdp_discoveries = [ - { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } - ] config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_HOSTNAME: TEST_HOSTNAME, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + mock_device = hass.data[DOMAIN][config_entry.entry_id].device - config = { - # no upnp, ensures no import-flow is started. + # Reset. + mock_device.traffic_times_polled = 0 + mock_device.status_times_polled = 0 + + # Forward time, ensure single poll after 30 (default) seconds. + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + assert mock_device.traffic_times_polled == 1 + assert mock_device.status_times_polled == 1 + + # Options flow with no input results in form. + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + # Options flow with input results in update to entry. + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONFIG_ENTRY_SCAN_INTERVAL: 60}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONFIG_ENTRY_SCAN_INTERVAL: 60, } - with patch.object( - Device, "async_create_device", AsyncMock(return_value=mock_device) - ), patch.object( - ssdp, - "async_get_discovery_info_by_udn_st", - Mock(return_value=ssdp_discoveries[0]), - ): - # Initialisation of component. - await async_setup_component(hass, "upnp", config) - await hass.async_block_till_done() - mock_device.times_polled = 0 # Reset. - # Forward time, ensure single poll after 30 (default) seconds. - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() - assert mock_device.times_polled == 1 + # Forward time, ensure single poll after 60 seconds, still from original setting. + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) + await hass.async_block_till_done() + assert mock_device.traffic_times_polled == 2 + assert mock_device.status_times_polled == 2 - # Options flow with no input results in form. - result = await hass.config_entries.options.async_init( - config_entry.entry_id, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + # Now the updated interval takes effect. + # Forward time, ensure single poll after 120 seconds. + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=121)) + await hass.async_block_till_done() + assert mock_device.traffic_times_polled == 3 + assert mock_device.status_times_polled == 3 - # Options flow with input results in update to entry. - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONFIG_ENTRY_SCAN_INTERVAL: 60}, - ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert config_entry.options == { - CONFIG_ENTRY_SCAN_INTERVAL: 60, - } - - # Forward time, ensure single poll after 60 seconds, still from original setting. - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) - await hass.async_block_till_done() - assert mock_device.times_polled == 2 - - # Now the updated interval takes effect. - # Forward time, ensure single poll after 120 seconds. - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=121)) - await hass.async_block_till_done() - assert mock_device.times_polled == 3 - - # Forward time, ensure single poll after 180 seconds. - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=181)) - await hass.async_block_till_done() - assert mock_device.times_polled == 4 + # Forward time, ensure single poll after 180 seconds. + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=181)) + await hass.async_block_till_done() + assert mock_device.traffic_times_polled == 4 + assert mock_device.status_times_polled == 4 diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index 0770906f0da..9ccdbf02f4b 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -1,6 +1,7 @@ """Test UPnP/IGD setup process.""" +from __future__ import annotations -from unittest.mock import AsyncMock, Mock, patch +import pytest from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( @@ -8,51 +9,37 @@ from homeassistant.components.upnp.const import ( CONFIG_ENTRY_UDN, DOMAIN, ) -from homeassistant.components.upnp.device import Device -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component -from .mock_device import MockDevice +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 tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_ssdp_scanner", "mock_upnp_device") async def test_async_setup_entry_default(hass: HomeAssistant): """Test async_setup_entry.""" - udn = "uuid:device_1" - location = "http://192.168.1.1/desc.xml" - mock_device = MockDevice(udn) - discovery = { - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - ssdp.ATTR_SSDP_USN: mock_device.usn, - } entry = MockConfigEntry( domain=DOMAIN, data={ - CONFIG_ENTRY_UDN: mock_device.udn, - CONFIG_ENTRY_ST: mock_device.device_type, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ST: TEST_ST, }, ) - config = { - # no upnp - } - async_create_device = AsyncMock(return_value=mock_device) - mock_get_discovery = Mock() - with patch.object(Device, "async_create_device", async_create_device), patch.object( - ssdp, "async_get_discovery_info_by_udn_st", mock_get_discovery - ): - # initialisation of component, no device discovered - mock_get_discovery.return_value = None - await async_setup_component(hass, "upnp", config) - await hass.async_block_till_done() + # Initialisation of component, no device discovered. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() - # loading of config_entry, device discovered - mock_get_discovery.return_value = discovery - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) is True + # 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 - # ensure device is stored/used - async_create_device.assert_called_with(hass, discovery[ssdp.ATTR_SSDP_LOCATION]) + # 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/uptimerobot/__init__.py b/tests/components/uptimerobot/__init__.py new file mode 100644 index 00000000000..b8f18655820 --- /dev/null +++ b/tests/components/uptimerobot/__init__.py @@ -0,0 +1 @@ +"""Tests for the Uptime Robot integration.""" diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py new file mode 100644 index 00000000000..aa241ce5a92 --- /dev/null +++ b/tests/components/uptimerobot/common.py @@ -0,0 +1,95 @@ +"""Common constants and functions for Uptime Robot tests.""" +from __future__ import annotations + +from enum import Enum +from typing import Any +from unittest.mock import patch + +from pyuptimerobot import ( + APIStatus, + UptimeRobotAccount, + UptimeRobotApiError, + UptimeRobotApiResponse, + UptimeRobotMonitor, +) + +from homeassistant import config_entries +from homeassistant.components.uptimerobot.const import DOMAIN +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_UPTIMEROBOT_API_KEY = "1234" +MOCK_UPTIMEROBOT_UNIQUE_ID = "1234567890" + +MOCK_UPTIMEROBOT_ACCOUNT = {"email": "test@test.test", "user_id": 1234567890} +MOCK_UPTIMEROBOT_ERROR = {"message": "test error from API."} +MOCK_UPTIMEROBOT_MONITOR = { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", +} + +MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA = { + "domain": DOMAIN, + "title": "test@test.test", + "data": {"platform": DOMAIN, "api_key": MOCK_UPTIMEROBOT_API_KEY}, + "unique_id": MOCK_UPTIMEROBOT_UNIQUE_ID, + "source": config_entries.SOURCE_USER, +} + +UPTIMEROBOT_TEST_ENTITY = "binary_sensor.test_monitor" + + +class MockApiResponseKey(str, Enum): + """Mock API response key.""" + + ACCOUNT = "account" + ERROR = "error" + MONITORS = "monitors" + + +def mock_uptimerobot_api_response( + data: dict[str, Any] + | None + | list[UptimeRobotMonitor] + | UptimeRobotAccount + | UptimeRobotApiError = None, + status: APIStatus = APIStatus.OK, + key: MockApiResponseKey = MockApiResponseKey.MONITORS, +) -> UptimeRobotApiResponse: + """Mock API response for Uptime Robot.""" + return UptimeRobotApiResponse.from_dict( + { + "stat": {"error": APIStatus.FAIL}.get(key, status), + key: data + if data is not None + else { + "account": MOCK_UPTIMEROBOT_ACCOUNT, + "error": MOCK_UPTIMEROBOT_ERROR, + "monitors": [MOCK_UPTIMEROBOT_MONITOR], + }.get(key, {}), + } + ) + + +async def setup_uptimerobot_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Uptime Robot integration.""" + mock_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) + mock_entry.add_to_hass(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(data=[MOCK_UPTIMEROBOT_MONITOR]), + ): + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert mock_entry.state == config_entries.ConfigEntryState.LOADED + + return mock_entry diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py new file mode 100644 index 00000000000..13bb3b342e9 --- /dev/null +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -0,0 +1,82 @@ +"""Test Uptime Robot binary_sensor.""" + +from unittest.mock import patch + +from pyuptimerobot import UptimeRobotAuthenticationException + +from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY +from homeassistant.components.uptimerobot.const import ( + ATTRIBUTION, + COORDINATOR_UPDATE_INTERVAL, + DOMAIN, +) +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from .common import ( + MOCK_UPTIMEROBOT_API_KEY, + MOCK_UPTIMEROBOT_MONITOR, + UPTIMEROBOT_TEST_ENTITY, + MockApiResponseKey, + mock_uptimerobot_api_response, + setup_uptimerobot_integration, +) + +from tests.common import async_fire_time_changed + + +async def test_config_import(hass: HomeAssistant) -> None: + """Test importing YAML configuration.""" + config = { + "binary_sensor": { + "platform": DOMAIN, + "api_key": MOCK_UPTIMEROBOT_API_KEY, + } + } + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(), + ): + assert await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + + assert len(config_entries) == 1 + config_entry = config_entries[0] + assert config_entry.source == "import" + + +async def test_presentation(hass: HomeAssistant) -> None: + """Test the presenstation of Uptime Robot binary_sensors.""" + await setup_uptimerobot_integration(hass) + + entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + + assert entity.state == STATE_ON + assert entity.attributes["device_class"] == DEVICE_CLASS_CONNECTIVITY + assert entity.attributes["attribution"] == ATTRIBUTION + assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] + + +async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: + """Test entity unaviable on update failure.""" + await setup_uptimerobot_integration(hass) + + entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + assert entity.state == STATE_ON + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + assert entity.state == STATE_UNAVAILABLE diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py new file mode 100644 index 00000000000..966483970d0 --- /dev/null +++ b/tests/components/uptimerobot/test_config_flow.py @@ -0,0 +1,356 @@ +"""Test the Uptime Robot config flow.""" +from unittest.mock import patch + +import pytest +from pytest import LogCaptureFixture +from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException + +from homeassistant import config_entries, setup +from homeassistant.components.uptimerobot.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from .common import ( + MOCK_UPTIMEROBOT_ACCOUNT, + MOCK_UPTIMEROBOT_API_KEY, + MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, + MOCK_UPTIMEROBOT_UNIQUE_ID, + MockApiResponseKey, + mock_uptimerobot_api_response, +) + +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( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + await hass.async_block_till_done() + + assert result2["result"].unique_id == MOCK_UPTIMEROBOT_UNIQUE_ID + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == MOCK_UPTIMEROBOT_ACCOUNT["email"] + assert result2["data"] == {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "exception,error_key", + [ + (Exception, "unknown"), + (UptimeRobotException, "cannot_connect"), + (UptimeRobotAuthenticationException, "invalid_api_key"), + ], +) +async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: + """Test that we handle exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"]["base"] == error_key + + +async def test_form_api_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + + assert result2["errors"]["base"] == "unknown" + assert "test error from API." in caplog.text + + +async def test_flow_import( + hass: HomeAssistant, +) -> None: + """Test an import flow.""" + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), patch( + "homeassistant.components.uptimerobot.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={"platform": DOMAIN, CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY} + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), patch( + "homeassistant.components.uptimerobot.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={"platform": DOMAIN, CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response( + key=MockApiResponseKey.ACCOUNT, data={} + ), + ), patch( + "homeassistant.components.uptimerobot.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={"platform": DOMAIN, CONF_API_KEY: "12345"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" + + +async def test_user_unique_id_already_exists( + hass: HomeAssistant, +) -> None: + """Test creating an entry where the unique_id already exists.""" + entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) + entry.add_to_hass(hass) + + 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( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "12345"}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_reauthentication( + hass: HomeAssistant, +) -> None: + """Test Uptime Robot reauthentication.""" + old_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauthentication_failure( + hass: HomeAssistant, +) -> None: + """Test Uptime Robot reauthentication failure.""" + old_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"]["base"] == "unknown" + + +async def test_reauthentication_failure_no_existing_entry( + hass: HomeAssistant, +) -> None: + """Test Uptime Robot reauthentication with no existing entry.""" + old_entry = MockConfigEntry( + **{**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, "unique_id": None} + ) + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_failed_existing" + + +async def test_reauthentication_failure_account_not_matching( + hass: HomeAssistant, +) -> None: + """Test Uptime Robot reauthentication failure when using another account.""" + old_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "reauth_confirm" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response( + key=MockApiResponseKey.ACCOUNT, + data={**MOCK_UPTIMEROBOT_ACCOUNT, "user_id": 1234567891}, + ), + ), patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"]["base"] == "reauth_failed_matching_account" diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py new file mode 100644 index 00000000000..43f78e7a19f --- /dev/null +++ b/tests/components/uptimerobot/test_init.py @@ -0,0 +1,187 @@ +"""Test the Uptime Robot init.""" +from unittest.mock import patch + +from pytest import LogCaptureFixture +from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException + +from homeassistant import config_entries +from homeassistant.components.uptimerobot.const import ( + COORDINATOR_UPDATE_INTERVAL, + DOMAIN, +) +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get_registry, +) +from homeassistant.util import dt + +from .common import ( + MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, + MOCK_UPTIMEROBOT_MONITOR, + UPTIMEROBOT_TEST_ENTITY, + MockApiResponseKey, + mock_uptimerobot_api_response, + setup_uptimerobot_integration, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_reauthentication_trigger_in_setup( + hass: HomeAssistant, caplog: LogCaptureFixture +): + """Test reauthentication trigger.""" + mock_config_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) + mock_config_entry.add_to_hass(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + assert mock_config_entry.reason == "could not authenticate" + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + assert ( + "Config entry 'test@test.test' for uptimerobot integration could not authenticate" + in caplog.text + ) + + +async def test_reauthentication_trigger_after_setup( + hass: HomeAssistant, caplog: LogCaptureFixture +): + """Test reauthentication trigger.""" + mock_config_entry = await setup_uptimerobot_integration(hass) + + binary_sensor = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED + assert binary_sensor.state == STATE_ON + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE + + assert "Authentication failed while fetching uptimerobot data" in caplog.text + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + +async def test_integration_reload(hass: HomeAssistant): + """Test integration reload.""" + mock_entry = await setup_uptimerobot_integration(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(), + ): + assert await hass.config_entries.async_reload(mock_entry.entry_id) + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry.state == config_entries.ConfigEntryState.LOADED + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + + +async def test_update_errors(hass: HomeAssistant, caplog: LogCaptureFixture): + """Test errors during updates.""" + await setup_uptimerobot_integration(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotException, + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(), + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE + + assert "Error fetching uptimerobot data: test error from API" in caplog.text + + +async def test_device_management(hass: HomeAssistant): + """Test that we are adding and removing devices for monitors returned from the API.""" + mock_entry = await setup_uptimerobot_integration(hass) + dev_reg = await async_get_registry(hass) + + devices = async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + assert len(devices) == 1 + + assert devices[0].identifiers == {(DOMAIN, "1234")} + assert devices[0].name == "Test monitor" + + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2") is None + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[MOCK_UPTIMEROBOT_MONITOR, {**MOCK_UPTIMEROBOT_MONITOR, "id": 12345}] + ), + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + devices = async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + assert len(devices) == 2 + assert devices[0].identifiers == {(DOMAIN, "1234")} + assert devices[1].identifiers == {(DOMAIN, "12345")} + + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2").state == STATE_ON + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(), + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + devices = async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + assert len(devices) == 1 + assert devices[0].identifiers == {(DOMAIN, "1234")} + + assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2") is None diff --git a/tests/components/usb/__init__.py b/tests/components/usb/__init__.py new file mode 100644 index 00000000000..7dbfdfdcff6 --- /dev/null +++ b/tests/components/usb/__init__.py @@ -0,0 +1,29 @@ +"""Tests for the USB Discovery integration.""" + + +from homeassistant.components.usb.models import USBDevice + +conbee_device = USBDevice( + device="/dev/cu.usbmodemDE24338801", + vid="1CF1", + pid="0030", + serial_number="DE2433880", + manufacturer="dresden elektronik ingenieurtechnik GmbH", + description="ConBee II", +) +slae_sh_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="10C4", + pid="EA60", + serial_number="00_12_4B_00_22_98_88_7F", + manufacturer="Silicon Labs", + description="slae.sh cc2652rb stick - slaesh's iot stuff", +) +electro_lama_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="1A86", + pid="7523", + serial_number=None, + manufacturer=None, + description="USB2.0-Serial", +) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py new file mode 100644 index 00000000000..6ba21222052 --- /dev/null +++ b/tests/components/usb/test_init.py @@ -0,0 +1,789 @@ +"""Tests for the USB Discovery integration.""" +import os +import sys +from unittest.mock import MagicMock, patch, sentinel + +import pytest + +from homeassistant.components import usb +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.setup import async_setup_component + +from . import conbee_device, slae_sh_device + + +@pytest.fixture(name="operating_system") +def mock_operating_system(): + """Mock running Home Assistant Operating system.""" + with patch( + "homeassistant.components.usb.system_info.async_get_system_info", + return_value={ + "hassio": True, + "docker": True, + }, + ): + yield + + +@pytest.fixture(name="docker") +def mock_docker(): + """Mock running Home Assistant in docker container.""" + with patch( + "homeassistant.components.usb.system_info.async_get_system_info", + return_value={ + "hassio": False, + "docker": True, + }, + ): + yield + + +@pytest.fixture(name="venv") +def mock_venv(): + """Mock running Home Assistant in a venv container.""" + with patch( + "homeassistant.components.usb.system_info.async_get_system_info", + return_value={ + "hassio": False, + "docker": False, + "virtualenv": True, + }, + ): + yield + + +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only works on linux", +) +async def test_discovered_by_observer_before_started(hass, operating_system): + """Test a device is discovered by the observer before started.""" + + async def _mock_monitor_observer_callback(callback): + await hass.async_add_executor_job( + callback, MagicMock(action="add", device_path="/dev/new") + ) + + def _create_mock_monitor_observer(monitor, callback, name): + hass.async_create_task(_mock_monitor_observer_callback(callback)) + return MagicMock() + + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + 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( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch( + "pyudev.MonitorObserver", new=_create_mock_monitor_observer + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + + with patch("homeassistant.components.usb.comports", return_value=[]), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only works on linux", +) +async def test_removal_by_observer_before_started(hass, operating_system): + """Test a device is removed by the observer before started.""" + + async def _mock_monitor_observer_callback(callback): + await hass.async_add_executor_job( + callback, MagicMock(action="remove", device_path="/dev/new") + ) + + def _create_mock_monitor_observer(monitor, callback, name): + hass.async_create_task(_mock_monitor_observer_callback(callback)) + return MagicMock() + + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + 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( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch( + "pyudev.MonitorObserver", new=_create_mock_monitor_observer + ), 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() + + with patch("homeassistant.components.usb.comports", return_value=[]): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_discovered_by_websocket_scan(hass, hass_ws_client): + """Test a device is discovered from websocket scan.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + 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] == "test1" + + +async def test_discovered_by_websocket_scan_limited_by_description_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the description matcher.""" + new_usb = [ + {"domain": "test1", "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] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_description_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan rejected by the description matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*not_it*"} + ] + + 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) == 0 + + +async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the serial_number matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "serial_number": "00_12_4b_00*", + } + ] + + 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] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is rejected by the serial_number matcher.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} + ] + + 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) == 0 + + +async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is limited by the manufacturer matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "manufacturer": "dresden elektronik ingenieurtechnik*", + } + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_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] == "test1" + + +async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( + hass, hass_ws_client +): + """Test a device is discovered from websocket scan is rejected by the manufacturer matcher.""" + new_usb = [ + { + "domain": "test1", + "vid": "3039", + "pid": "3039", + "manufacturer": "other vendor*", + } + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_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) == 0 + + +async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( + hass, hass_ws_client +): + """Test a device is discovered from websocket is rejected with empty serial number.""" + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} + ] + + mock_comports = [ + MagicMock( + device=conbee_device.device, + vid=12345, + pid=12345, + serial_number=None, + manufacturer=None, + description=None, + ) + ] + + 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) == 0 + + +async def test_discovered_by_websocket_scan_match_vid_only(hass, hass_ws_client): + """Test a device is discovered from websocket scan only matching vid.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + + 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] == "test1" + + +async def test_discovered_by_websocket_scan_match_vid_wrong_pid(hass, hass_ws_client): + """Test a device is discovered from websocket scan only matching vid but wrong pid.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] + + 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) == 0 + + +async def test_discovered_by_websocket_no_vid_pid(hass, hass_ws_client): + """Test a device is discovered from websocket scan with no vid or pid.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=None, + pid=None, + 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) == 0 + + +@pytest.mark.parametrize("exception_type", [ImportError, OSError]) +async def test_non_matching_discovered_by_scanner_after_started( + hass, exception_type, hass_ws_client +): + """Test a websocket scan that does not match.""" + new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] + + 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=exception_type), 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) == 0 + + +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only works on linux", +) +async def test_observer_on_wsl_fallback_without_throwing_exception( + hass, hass_ws_client, venv +): + """Test that observer on WSL failure results in fallback to scanning without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + + 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"), patch( + "pyudev.Monitor.filter_by", side_effect=ValueError + ), 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] == "test1" + + +@pytest.mark.skipif( + not sys.platform.startswith("linux"), + reason="Only works on linux", +) +async def test_not_discovered_by_observer_before_started_on_docker(hass, docker): + """Test a device is not discovered since observer is not running on bare docker.""" + + async def _mock_monitor_observer_callback(callback): + await hass.async_add_executor_job( + callback, MagicMock(action="add", device_path="/dev/new") + ) + + def _create_mock_monitor_observer(monitor, callback, name): + hass.async_create_task(_mock_monitor_observer_callback(callback)) + return MagicMock() + + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + 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( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch( + "pyudev.MonitorObserver", new=_create_mock_monitor_observer + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + + with patch("homeassistant.components.usb.comports", return_value=[]), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +def test_get_serial_by_id_no_dir(): + """Test serial by id conversion if there's no /dev/serial/by-id.""" + p1 = patch("os.path.isdir", MagicMock(return_value=False)) + p2 = patch("os.scandir") + with p1 as is_dir_mock, p2 as scan_mock: + res = usb.get_serial_by_id(sentinel.path) + assert res is sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 0 + + +def test_get_serial_by_id(): + """Test serial by id conversion.""" + p1 = patch("os.path.isdir", MagicMock(return_value=True)) + p2 = patch("os.scandir") + + def _realpath(path): + if path is sentinel.matched_link: + return sentinel.path + return sentinel.serial_link_path + + p3 = patch("os.path.realpath", side_effect=_realpath) + with p1 as is_dir_mock, p2 as scan_mock, p3: + res = usb.get_serial_by_id(sentinel.path) + assert res is sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 1 + + entry1 = MagicMock(spec_set=os.DirEntry) + entry1.is_symlink.return_value = True + entry1.path = sentinel.some_path + + entry2 = MagicMock(spec_set=os.DirEntry) + entry2.is_symlink.return_value = False + entry2.path = sentinel.other_path + + entry3 = MagicMock(spec_set=os.DirEntry) + entry3.is_symlink.return_value = True + entry3.path = sentinel.matched_link + + scan_mock.return_value = [entry1, entry2, entry3] + res = usb.get_serial_by_id(sentinel.path) + assert res is sentinel.matched_link + assert is_dir_mock.call_count == 2 + assert scan_mock.call_count == 2 + + +def test_human_readable_device_name(): + """Test human readable device name includes the passed data.""" + name = usb.human_readable_device_name( + "/dev/null", + "612020FD", + "Silicon Labs", + "HubZ Smart Home Controller - HubZ Z-Wave Com Port", + "10C4", + "8A2A", + ) + assert "/dev/null" in name + assert "612020FD" in name + assert "Silicon Labs" in name + assert "HubZ Smart Home Controller - HubZ Z-Wave Com Port"[:26] in name + assert "10C4" in name + assert "8A2A" in name + + name = usb.human_readable_device_name( + "/dev/null", + "612020FD", + "Silicon Labs", + None, + "10C4", + "8A2A", + ) + assert "/dev/null" in name + assert "612020FD" in name + assert "Silicon Labs" in name + assert "10C4" in name + assert "8A2A" in name diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index c422d3b5c1f..aa6de34f611 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -10,15 +10,49 @@ from homeassistant.components.utility_meter.const import ( SERVICE_SELECT_NEXT_TARIFF, SERVICE_SELECT_TARIFF, ) +import homeassistant.components.utility_meter.sensor as um_sensor from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONF_PLATFORM, ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_START, ) +from homeassistant.core import State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import mock_restore_cache + + +async def test_restore_state(hass): + """Test utility sensor restore state.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "tariffs": ["onpeak", "midpeak", "offpeak"], + } + } + } + mock_restore_cache( + hass, + [ + State( + "utility_meter.energy_bill", + "midpeak", + ), + ], + ) + + assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + # restore from cache + state = hass.states.get("utility_meter.energy_bill") + assert state.state == "midpeak" + async def test_services(hass): """Test energy sensor reset service.""" @@ -81,6 +115,13 @@ async def test_services(hass): assert state.state == "1" # Change tariff + data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "wrong_tariff"} + await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data) + await hass.async_block_till_done() + + # Inexisting tariff, ignoring + assert hass.states.get("utility_meter.energy_bill").state != "wrong_tariff" + data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "peak"} await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data) await hass.async_block_till_done() @@ -111,3 +152,82 @@ async def test_services(hass): state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "0" + + +async def test_cron(hass, legacy_patchable_time): + """Test cron pattern and offset fails.""" + + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "cron": "*/5 * * * *", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + +async def test_cron_and_meter(hass, legacy_patchable_time): + """Test cron pattern and meter type fails.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "cycle": "hourly", + "cron": "0 0 1 * *", + } + } + } + + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_both_cron_and_meter(hass, legacy_patchable_time): + """Test cron pattern and meter type passes in different meter.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "cron": "0 0 1 * *", + }, + "water_bill": { + "source": "sensor.water", + "cycle": "hourly", + }, + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + +async def test_cron_and_offset(hass, legacy_patchable_time): + """Test cron pattern and offset fails.""" + + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "offset": {"days": 1}, + "cron": "0 0 1 * *", + } + } + } + + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_bad_cron(hass, legacy_patchable_time): + """Test bad cron pattern.""" + + config = { + "utility_meter": {"energy_bill": {"source": "sensor.energy", "cron": "*"}} + } + + assert not await async_setup_component(hass, DOMAIN, config) + + +async def test_setup_missing_discovery(hass): + """Test setup with configuration missing discovery_info.""" + assert not await um_sensor.async_setup_platform(hass, {CONF_PLATFORM: DOMAIN}, None) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c5075aa322b..5627daec7f8 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -3,11 +3,18 @@ from contextlib import contextmanager from datetime import timedelta from unittest.mock import patch -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.components.utility_meter.const import ( ATTR_TARIFF, ATTR_VALUE, + DAILY, DOMAIN, + HOURLY, + QUARTER_HOURLY, SERVICE_CALIBRATE_METER, SERVICE_SELECT_TARIFF, ) @@ -23,6 +30,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_START, + STATE_UNAVAILABLE, ) from homeassistant.core import State from homeassistant.setup import async_setup_component @@ -158,6 +166,26 @@ async def test_state(hass): assert state is not None assert state.state == "0.123" + # test invalid state + entity_id = config[DOMAIN]["energy_bill"]["source"] + hass.states.async_set( + entity_id, "*", {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill_midpeak") + assert state is not None + assert state.state == "0.123" + + # test unavailable source + entity_id = config[DOMAIN]["energy_bill"]["source"] + hass.states.async_set( + entity_id, STATE_UNAVAILABLE, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill_midpeak") + assert state is not None + assert state.state == "0.123" + async def test_device_class(hass): """Test utility device_class.""" @@ -165,6 +193,7 @@ async def test_device_class(hass): "utility_meter": { "energy_meter": { "source": "sensor.energy", + "net_consumption": True, }, "gas_meter": { "source": "sensor.gas", @@ -197,7 +226,7 @@ async def test_device_class(hass): 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_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None hass.states.async_set( @@ -219,7 +248,7 @@ async def test_device_class(hass): assert state is not None assert state.state == "1" assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "some_archaic_unit" @@ -416,6 +445,44 @@ async def _test_self_reset(hass, config, start_time, expect_reset=True): start_time_str = dt_util.parse_datetime(start_time).isoformat() assert state.attributes.get("last_reset") == start_time_str + # Check next day when nothing should happen for weekly, monthly, bimonthly and yearly + if config["utility_meter"]["energy_bill"].get("cycle") in [ + QUARTER_HOURLY, + HOURLY, + DAILY, + ]: + now += timedelta(minutes=5) + else: + now += timedelta(days=5) + with alter_time(now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + hass.states.async_set( + entity_id, + 10, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill") + if expect_reset: + assert state.attributes.get("last_period") == "2" + assert state.state == "7" + else: + assert state.attributes.get("last_period") == 0 + assert state.state == "9" + + +async def test_self_reset_cron_pattern(hass, legacy_patchable_time): + """Test cron pattern reset of meter.""" + config = { + "utility_meter": { + "energy_bill": {"source": "sensor.energy", "cron": "0 0 1 * *"} + } + } + + await _test_self_reset(hass, config, "2017-01-31T23:59:00.000000+00:00") + async def test_self_reset_quarter_hourly(hass, legacy_patchable_time): """Test quarter-hourly reset of meter.""" diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index 164b4090e5f..cd56223a1e6 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -1,26 +1,130 @@ """The test for the version sensor platform.""" +from datetime import timedelta from unittest.mock import patch +from pyhaversion import HaVersionSource, exceptions as pyhaversionexceptions +import pytest + +from homeassistant.components.version.sensor import HA_VERSION_SOURCES from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from tests.common import async_fire_time_changed MOCK_VERSION = "10.0" -async def test_version_sensor(hass): - """Test the Version sensor.""" - config = {"sensor": {"platform": "version"}} +@pytest.mark.parametrize( + "source,target_source,name", + ( + ( + ("local", HaVersionSource.LOCAL, "current_version"), + ("docker", HaVersionSource.CONTAINER, "latest_version"), + ("hassio", HaVersionSource.SUPERVISOR, "latest_version"), + ) + + tuple( + (source, HaVersionSource(source), "latest_version") + for source in HA_VERSION_SOURCES + if source != HaVersionSource.LOCAL + ) + ), +) +async def test_version_source(hass, source, target_source, name): + """Test the Version sensor with different sources.""" + config = { + "sensor": {"platform": "version", "source": source, "image": "qemux86-64"} + } - assert await async_setup_component(hass, "sensor", config) - - -async def test_version(hass): - """Test the Version sensor.""" - config = {"sensor": {"platform": "version", "name": "test"}} - - with patch("homeassistant.const.__version__", MOCK_VERSION): + with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( + "homeassistant.components.version.sensor.HaVersion.version", MOCK_VERSION + ): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() - state = hass.states.get("sensor.test") + state = hass.states.get(f"sensor.{name}") + assert state + assert state.attributes["source"] == target_source - assert state.state == "10.0" + assert state.state == MOCK_VERSION + + +async def test_version_fetch_exception(hass, caplog): + """Test fetch exception thrown during updates.""" + config = {"sensor": {"platform": "version"}} + with patch( + "homeassistant.components.version.sensor.HaVersion.get_version", + side_effect=pyhaversionexceptions.HaVersionFetchException( + "Fetch exception from pyhaversion" + ), + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + assert "Fetch exception from pyhaversion" in caplog.text + + +async def test_version_parse_exception(hass, caplog): + """Test parse exception thrown during updates.""" + config = {"sensor": {"platform": "version"}} + with patch( + "homeassistant.components.version.sensor.HaVersion.get_version", + side_effect=pyhaversionexceptions.HaVersionParseException, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + assert "Could not parse data received for HaVersionSource.LOCAL" in caplog.text + + +async def test_update(hass): + """Test updates.""" + config = {"sensor": {"platform": "version"}} + + with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( + "homeassistant.components.version.sensor.HaVersion.version", MOCK_VERSION + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_version") + assert state + assert state.state == MOCK_VERSION + + with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( + "homeassistant.components.version.sensor.HaVersion.version", "1234" + ): + + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_version") + assert state + assert state.state == "1234" + + +async def test_image_name_container(hass): + """Test the Version sensor with image name for container.""" + config = { + "sensor": {"platform": "version", "source": "docker", "image": "qemux86-64"} + } + + with patch("homeassistant.components.version.sensor.HaVersion") as haversion: + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + constructor = haversion.call_args[1] + assert constructor["source"] == "container" + assert constructor["image"] == "qemux86-64-homeassistant" + + +async def test_image_name_supervisor(hass): + """Test the Version sensor with image name for supervisor.""" + config = { + "sensor": {"platform": "version", "source": "hassio", "image": "qemux86-64"} + } + + with patch("homeassistant.components.version.sensor.HaVersion") as haversion: + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + constructor = haversion.call_args[1] + assert constructor["source"] == "supervisor" + assert constructor["image"] == "qemux86-64" diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index 4449859ddb2..e1dbda1dd04 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -29,6 +29,7 @@ class TestVultrSensorSetup(unittest.TestCase): def add_entities(self, devices, action): """Mock add devices.""" for device in devices: + device.hass = self.hass self.DEVICES.append(device) def setUp(self): diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 7766fe512cc..08abd140dac 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -22,8 +22,8 @@ def pywemo_model_fixture(): return "LightSwitch" -@pytest.fixture(name="pywemo_registry") -def pywemo_registry_fixture(): +@pytest.fixture(name="pywemo_registry", autouse=True) +async def async_pywemo_registry_fixture(): """Fixture for SubscriptionRegistry instances.""" registry = create_autospec(pywemo.SubscriptionRegistry, instance=True) @@ -35,11 +35,19 @@ def pywemo_registry_fixture(): registry.semaphore.release() registry.on.side_effect = on_func + registry.is_subscribed.return_value = False with patch("pywemo.SubscriptionRegistry", return_value=registry): yield registry +@pytest.fixture(name="pywemo_discovery_responder", autouse=True) +def pywemo_discovery_responder_fixture(): + """Fixture for the DiscoveryResponder instance.""" + with patch("pywemo.ssdp.DiscoveryResponder", autospec=True): + yield + + @pytest.fixture(name="pywemo_device") def pywemo_device_fixture(pywemo_registry, pywemo_model): """Fixture for WeMoDevice instances.""" @@ -60,8 +68,14 @@ def pywemo_device_fixture(pywemo_registry, pywemo_model): yield device +@pytest.fixture(name="wemo_entity_suffix") +def wemo_entity_suffix_fixture(): + """Fixture to select a specific entity for wemo_entity.""" + return "" + + @pytest.fixture(name="wemo_entity") -async def async_wemo_entity_fixture(hass, pywemo_device): +async def async_wemo_entity_fixture(hass, pywemo_device, wemo_entity_suffix): """Fixture for a Wemo entity in hass.""" assert await async_setup_component( hass, @@ -76,7 +90,8 @@ async def async_wemo_entity_fixture(hass, pywemo_device): await hass.async_block_till_done() entity_registry = er.async_get(hass) - entity_entries = list(entity_registry.entities.values()) - assert len(entity_entries) == 1 + for entry in entity_registry.entities.values(): + if entry.entity_id.endswith(wemo_entity_suffix): + return entry - yield entity_entries[0] + return None diff --git a/tests/components/wemo/entity_test_helpers.py b/tests/components/wemo/entity_test_helpers.py index 3d1a73941e6..6836f87a4a0 100644 --- a/tests/components/wemo/entity_test_helpers.py +++ b/tests/components/wemo/entity_test_helpers.py @@ -4,196 +4,133 @@ This is not a test module. These test methods are used by the platform test modu """ import asyncio import threading -from unittest.mock import patch -import async_timeout -import pywemo -from pywemo.ouimeaux_device.api.service import ActionException - -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, +from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN +from homeassistant.components.wemo import wemo_device +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNAVAILABLE, ) -from homeassistant.components.wemo.const import SIGNAL_WEMO_STATE_PUSH -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component -def _perform_registry_callback(hass, pywemo_registry, pywemo_device): +def _perform_registry_callback(coordinator): """Return a callable method to trigger a state callback from the device.""" async def async_callback(): - event = asyncio.Event() - - async def event_callback(e, *args): - event.set() - - stop_dispatcher_listener = async_dispatcher_connect( - hass, SIGNAL_WEMO_STATE_PUSH, event_callback + await coordinator.hass.async_add_executor_job( + coordinator.subscription_callback, coordinator.wemo, "", "" ) - # Cause a state update callback to be triggered by the device. - await hass.async_add_executor_job( - pywemo_registry.callbacks[pywemo_device.name], pywemo_device, "", "" - ) - await event.wait() - stop_dispatcher_listener() return async_callback -def _perform_async_update(hass, wemo_entity): +def _perform_async_update(coordinator): """Return a callable method to cause hass to update the state of the entity.""" - @callback - def async_callback(): - return hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) + async def async_callback(): + await coordinator._async_update_data() return async_callback -async def _async_multiple_call_helper( - hass, - pywemo_registry, - wemo_entity, - pywemo_device, - call1, - call2, - update_polling_method=None, -): +async def _async_multiple_call_helper(hass, pywemo_device, call1, call2): """Create two calls (call1 & call2) in parallel; verify only one polls the device. - The platform entity should only perform one update poll on the device at a time. - Any parallel updates that happen at the same time should be ignored. This is - verified by blocking in the update polling method. The polling method should - only be called once as a result of calling call1 & call2 simultaneously. + There should only be one poll on the device at a time. Any parallel updates + # that happen at the same time should be ignored. This is verified by blocking + in the get_state method. The polling method should only be called once as a + result of calling call1 & call2 simultaneously. """ - # get_state is called outside the event loop. Use non-async Python Event. event = threading.Event() waiting = asyncio.Event() + call_count = 0 - def get_update(force_update=True): + def get_state(force_update=None): + if force_update is None: + return + nonlocal call_count + call_count += 1 hass.add_job(waiting.set) event.wait() - update_polling_method = update_polling_method or pywemo_device.get_state - update_polling_method.side_effect = get_update + # Danger! Do not use a Mock side_effect here. The test will deadlock. When + # called though hass.async_add_executor_job, Mock objects !surprisingly! + # run in the same thread as the asyncio event loop. + # https://github.com/home-assistant/core/blob/1ba5c1c9fb1e380549cb655986b5f4d3873d7352/tests/common.py#L179 + pywemo_device.get_state = get_state # One of these two calls will block on `event`. The other will return right # away because the `_update_lock` is held. - _, pending = await asyncio.wait( + done, pending = await asyncio.wait( [call1(), call2()], return_when=asyncio.FIRST_COMPLETED ) + _ = [d.result() for d in done] # Allow any exceptions to be raised. # Allow the blocked call to return. await waiting.wait() event.set() + if pending: - await asyncio.wait(pending) + done, _ = await asyncio.wait(pending) + _ = [d.result() for d in done] # Allow any exceptions to be raised. # Make sure the state update only happened once. - update_polling_method.assert_called_once() + assert call_count == 1 async def test_async_update_locked_callback_and_update( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs + hass, pywemo_device, wemo_entity ): """Test that a callback and a state update request can't both happen at the same time. When a state update is received via a callback from the device at the same time as hass is calling `async_update`, verify that only one of the updates proceeds. """ + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - callback = _perform_registry_callback(hass, pywemo_registry, pywemo_device) - update = _perform_async_update(hass, wemo_entity) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, callback, update, **kwargs - ) + callback = _perform_registry_callback(coordinator) + update = _perform_async_update(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, callback, update) -async def test_async_update_locked_multiple_updates( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs -): +async def test_async_update_locked_multiple_updates(hass, pywemo_device, wemo_entity): """Test that two hass async_update state updates do not proceed at the same time.""" + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - update = _perform_async_update(hass, wemo_entity) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, update, update, **kwargs - ) + update = _perform_async_update(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, update, update) -async def test_async_update_locked_multiple_callbacks( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs -): +async def test_async_update_locked_multiple_callbacks(hass, pywemo_device, wemo_entity): """Test that two device callback state updates do not proceed at the same time.""" + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - callback = _perform_registry_callback(hass, pywemo_registry, pywemo_device) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, callback, callback, **kwargs - ) + callback = _perform_registry_callback(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, callback, callback) -async def test_async_locked_update_with_exception( - hass, - wemo_entity, - pywemo_device, - update_polling_method=None, - expected_state=STATE_OFF, +async def test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, domain ): - """Test that the entity becomes unavailable when communication is lost.""" - assert hass.states.get(wemo_entity.entity_id).state == expected_state - await async_setup_component(hass, HA_DOMAIN, {}) - update_polling_method = update_polling_method or pywemo_device.get_state - update_polling_method.side_effect = ActionException + """Test the avaliability when an On call fails and after an update. + + This test expects that the pywemo_device Mock has been setup to raise an + ActionException when the SERVICE_TURN_ON method is called and that the + state will be On after the update. + """ + await async_setup_component(hass, domain, {}) await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, + domain, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, blocking=True, ) - assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE - -async def test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device, expected_state=STATE_OFF -): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - assert hass.states.get(wemo_entity.entity_id).state == expected_state - await async_setup_component(hass, HA_DOMAIN, {}) - - event = threading.Event() - - def get_state(*args): - event.wait() - return 0 - - if hasattr(pywemo_device, "bridge_update"): - pywemo_device.bridge_update.side_effect = get_state - elif isinstance(pywemo_device, pywemo.Insight): - pywemo_device.update_insight_params.side_effect = get_state - else: - pywemo_device.get_state.side_effect = get_state - timeout = async_timeout.timeout(0) - - with patch("async_timeout.timeout", return_value=timeout): - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) - - assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE - - # Check that the entity recovers and is available after the update succeeds. - event.set() + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") await hass.async_block_till_done() - assert hass.states.get(wemo_entity.entity_id).state == expected_state + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON diff --git a/tests/components/wemo/test_binary_sensor.py b/tests/components/wemo/test_binary_sensor.py index 1bf6f0f3bef..26e4981203d 100644 --- a/tests/components/wemo/test_binary_sensor.py +++ b/tests/components/wemo/test_binary_sensor.py @@ -6,77 +6,190 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.wemo.binary_sensor import ( + InsightBinarySensor, + MakerBinarySensor, +) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component from . import entity_test_helpers -@pytest.fixture -def pywemo_model(): - """Pywemo Motion models use the binary_sensor platform.""" - return "Motion" +class EntityTestHelpers: + """Common state update helpers.""" + + async def test_async_update_locked_multiple_updates( + self, hass, pywemo_device, wemo_entity + ): + """Test that two hass async_update state updates do not proceed at the same time.""" + await entity_test_helpers.test_async_update_locked_multiple_updates( + hass, pywemo_device, wemo_entity + ) + + async def test_async_update_locked_multiple_callbacks( + self, hass, pywemo_device, wemo_entity + ): + """Test that two device callback state updates do not proceed at the same time.""" + await entity_test_helpers.test_async_update_locked_multiple_callbacks( + hass, pywemo_device, wemo_entity + ) + + async def test_async_update_locked_callback_and_update( + self, hass, pywemo_device, wemo_entity + ): + """Test that a callback and a state update request can't both happen at the same time. + + When a state update is received via a callback from the device at the same time + as hass is calling `async_update`, verify that only one of the updates proceeds. + """ + await entity_test_helpers.test_async_update_locked_callback_and_update( + hass, pywemo_device, wemo_entity + ) -# Tests that are in common among wemo platforms. These test methods will be run -# in the scope of this test module. They will run using the pywemo_model from -# this test module (Motion). -test_async_update_locked_multiple_updates = ( - entity_test_helpers.test_async_update_locked_multiple_updates -) -test_async_update_locked_multiple_callbacks = ( - entity_test_helpers.test_async_update_locked_multiple_callbacks -) -test_async_update_locked_callback_and_update = ( - entity_test_helpers.test_async_update_locked_callback_and_update -) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) +class TestMotion(EntityTestHelpers): + """Test for the pyWeMo Motion device.""" + + @pytest.fixture + def pywemo_model(self): + """Pywemo Motion models use the binary_sensor platform.""" + return "Motion" + + async def test_binary_sensor_registry_state_callback( + self, hass, pywemo_registry, pywemo_device, wemo_entity + ): + """Verify that the binary_sensor receives state updates from the registry.""" + # On state. + pywemo_device.get_state.return_value = 1 + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + # Off state. + pywemo_device.get_state.return_value = 0 + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + + async def test_binary_sensor_update_entity( + self, hass, pywemo_registry, pywemo_device, wemo_entity + ): + """Verify that the binary_sensor performs state updates.""" + await async_setup_component(hass, HA_DOMAIN, {}) + + # On state. + pywemo_device.get_state.return_value = 1 + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, + blocking=True, + ) + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + # Off state. + pywemo_device.get_state.return_value = 0 + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, + blocking=True, + ) + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF -async def test_binary_sensor_registry_state_callback( - hass, pywemo_registry, pywemo_device, wemo_entity -): - """Verify that the binary_sensor receives state updates from the registry.""" - # On state. - pywemo_device.get_state.return_value = 1 - pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") - await hass.async_block_till_done() - assert hass.states.get(wemo_entity.entity_id).state == STATE_ON +class TestMaker(EntityTestHelpers): + """Test for the pyWeMo Maker device.""" - # Off state. - pywemo_device.get_state.return_value = 0 - pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") - await hass.async_block_till_done() - assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + @pytest.fixture + def pywemo_model(self): + """Pywemo Motion models use the binary_sensor platform.""" + return "Maker" + + @pytest.fixture + def wemo_entity_suffix(self): + """Select the MakerBinarySensor entity.""" + return MakerBinarySensor._name_suffix.lower() + + @pytest.fixture(name="pywemo_device") + def pywemo_device_fixture(self, pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.maker_params = { + "hassensor": 1, + "sensorstate": 1, + "switchmode": 1, + "switchstate": 0, + } + pywemo_device.has_sensor = pywemo_device.maker_params["hassensor"] + pywemo_device.sensor_state = pywemo_device.maker_params["sensorstate"] + yield pywemo_device + + async def test_registry_state_callback( + self, hass, pywemo_registry, pywemo_device, wemo_entity + ): + """Verify that the binary_sensor receives state updates from the registry.""" + # On state. + pywemo_device.sensor_state = 0 + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + # Off state. + pywemo_device.sensor_state = 1 + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF -async def test_binary_sensor_update_entity( - hass, pywemo_registry, pywemo_device, wemo_entity -): - """Verify that the binary_sensor performs state updates.""" - await async_setup_component(hass, HA_DOMAIN, {}) +class TestInsight(EntityTestHelpers): + """Test for the pyWeMo Insight device.""" - # On state. - pywemo_device.get_state.return_value = 1 - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) - assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + @pytest.fixture + def pywemo_model(self): + """Pywemo Motion models use the binary_sensor platform.""" + return "Insight" - # Off state. - pywemo_device.get_state.return_value = 0 - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) - assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + @pytest.fixture + def wemo_entity_suffix(self): + """Select the InsightBinarySensor entity.""" + return InsightBinarySensor._name_suffix.lower() + + @pytest.fixture(name="pywemo_device") + def pywemo_device_fixture(self, pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.insight_params = { + "currentpower": 1.0, + "todaymw": 200000000.0, + "state": "0", + "onfor": 0, + "ontoday": 0, + "ontotal": 0, + "powerthreshold": 0, + } + yield pywemo_device + + async def test_registry_state_callback( + self, hass, pywemo_registry, pywemo_device, wemo_entity + ): + """Verify that the binary_sensor receives state updates from the registry.""" + # On state. + pywemo_device.get_state.return_value = 1 + pywemo_device.insight_params["state"] = "1" + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + # Standby (Off) state. + pywemo_device.get_state.return_value = 1 + pywemo_device.insight_params["state"] = "8" + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + + # Off state. + pywemo_device.get_state.return_value = 0 + pywemo_device.insight_params["state"] = "1" + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") + await hass.async_block_till_done() + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF diff --git a/tests/components/wemo/test_fan.py b/tests/components/wemo/test_fan.py index 38055ba972c..dc450311e6a 100644 --- a/tests/components/wemo/test_fan.py +++ b/tests/components/wemo/test_fan.py @@ -1,7 +1,9 @@ """Tests for the Wemo fan entity.""" import pytest +from pywemo.exceptions import ActionException +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -32,12 +34,6 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) async def test_fan_registry_state_callback( @@ -82,6 +78,17 @@ async def test_fan_update_entity(hass, pywemo_registry, pywemo_device, wemo_enti assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.set_state.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, FAN_DOMAIN + ) + + async def test_fan_reset_filter_service(hass, pywemo_device, wemo_entity): """Verify that SERVICE_RESET_FILTER_LIFE is registered and works.""" assert await hass.services.async_call( diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index 573f75a66d9..b00cfe30ef7 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -1,5 +1,5 @@ """Tests for the Wemo light entity via the bridge.""" -from unittest.mock import create_autospec, patch +from unittest.mock import create_autospec import pytest import pywemo @@ -8,10 +8,9 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.wemo.light import MIN_TIME_BETWEEN_SCANS +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from . import entity_test_helpers @@ -32,60 +31,53 @@ def pywemo_bridge_light_fixture(pywemo_device): light.uniqueID = pywemo_device.serialnumber light.name = pywemo_device.name light.bridge = pywemo_device - light.state = {"onoff": 0} + light.state = {"onoff": 0, "available": True} pywemo_device.Lights = {pywemo_device.serialnumber: light} return light -def _bypass_throttling(): - """Bypass the util.Throttle on the update_lights method.""" - utcnow = dt_util.utcnow() - - def increment_and_return_time(): - nonlocal utcnow - utcnow += MIN_TIME_BETWEEN_SCANS - return utcnow - - return patch("homeassistant.util.utcnow", side_effect=increment_and_return_time) +async def test_async_update_locked_callback_and_update( + hass, pywemo_bridge_light, wemo_entity, pywemo_device +): + """Test that a callback and a state update request can't both happen at the same time.""" + await entity_test_helpers.test_async_update_locked_callback_and_update( + hass, + pywemo_device, + wemo_entity, + ) async def test_async_update_locked_multiple_updates( - hass, pywemo_registry, pywemo_bridge_light, wemo_entity, pywemo_device + hass, pywemo_bridge_light, wemo_entity, pywemo_device ): """Test that two state updates do not proceed at the same time.""" - pywemo_device.bridge_update.reset_mock() - - with _bypass_throttling(): - await entity_test_helpers.test_async_update_locked_multiple_updates( - hass, - pywemo_registry, - wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.bridge_update, - ) + await entity_test_helpers.test_async_update_locked_multiple_updates( + hass, + pywemo_device, + wemo_entity, + ) -async def test_async_update_with_timeout_and_recovery( +async def test_async_update_locked_multiple_callbacks( hass, pywemo_bridge_light, wemo_entity, pywemo_device ): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - with _bypass_throttling(): - await entity_test_helpers.test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device - ) + """Test that two device callback state updates do not proceed at the same time.""" + await entity_test_helpers.test_async_update_locked_multiple_callbacks( + hass, + pywemo_device, + wemo_entity, + ) -async def test_async_locked_update_with_exception( - hass, pywemo_bridge_light, wemo_entity, pywemo_device +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, pywemo_bridge_light, wemo_entity ): - """Test that the entity becomes unavailable when communication is lost.""" - with _bypass_throttling(): - await entity_test_helpers.test_async_locked_update_with_exception( - hass, - wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.bridge_update, - ) + """Test the avaliability when an On call fails and after an update.""" + pywemo_bridge_light.turn_on.side_effect = pywemo.exceptions.ActionException + pywemo_bridge_light.state["onoff"] = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, LIGHT_DOMAIN + ) async def test_light_update_entity( @@ -95,7 +87,7 @@ async def test_light_update_entity( await async_setup_component(hass, HA_DOMAIN, {}) # On state. - pywemo_bridge_light.state = {"onoff": 1} + pywemo_bridge_light.state["onoff"] = 1 await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -105,7 +97,7 @@ async def test_light_update_entity( assert hass.states.get(wemo_entity.entity_id).state == STATE_ON # Off state. - pywemo_bridge_light.state = {"onoff": 0} + pywemo_bridge_light.state["onoff"] = 0 await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, diff --git a/tests/components/wemo/test_light_dimmer.py b/tests/components/wemo/test_light_dimmer.py index 45fdd01a643..830eb6dbdf4 100644 --- a/tests/components/wemo/test_light_dimmer.py +++ b/tests/components/wemo/test_light_dimmer.py @@ -1,11 +1,13 @@ """Tests for the Wemo standalone/non-bridge light entity.""" import pytest +from pywemo.exceptions import ActionException from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -30,12 +32,17 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) + + +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.on.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, LIGHT_DOMAIN + ) async def test_light_registry_state_callback( diff --git a/tests/components/wemo/test_sensor.py b/tests/components/wemo/test_sensor.py index 3b8786131a7..eb322d469cd 100644 --- a/tests/components/wemo/test_sensor.py +++ b/tests/components/wemo/test_sensor.py @@ -6,14 +6,10 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC -from homeassistant.components.wemo.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE -from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from . import entity_test_helpers -from .conftest import MOCK_HOST, MOCK_PORT @pytest.fixture @@ -44,96 +40,43 @@ class InsightTestTemplate: EXPECTED_STATE_VALUE: str INSIGHT_PARAM_NAME: str - @pytest.fixture(name="wemo_entity") + @pytest.fixture(name="wemo_entity_suffix") @classmethod - async def async_wemo_entity_fixture(cls, hass, pywemo_device): - """Fixture for a Wemo entity in hass.""" - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_DISCOVERY: False, - CONF_STATIC: [f"{MOCK_HOST}:{MOCK_PORT}"], - }, - }, - ) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - correct_entity = None - to_remove = [] - for entry in entity_registry.entities.values(): - if entry.entity_id.endswith(cls.ENTITY_ID_SUFFIX): - correct_entity = entry - else: - to_remove.append(entry.entity_id) - - for removal in to_remove: - entity_registry.async_remove(removal) - assert len(entity_registry.entities) == 1 - return correct_entity + def wemo_entity_suffix_fixture(cls): + """Select the appropriate entity for the test.""" + return cls.ENTITY_ID_SUFFIX # Tests that are in common among wemo platforms. These test methods will be run # in the scope of this test module. They will run using the pywemo_model from # this test module (Insight). async def test_async_update_locked_multiple_updates( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that two hass async_update state updates do not proceed at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_multiple_updates( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, + wemo_entity, ) async def test_async_update_locked_multiple_callbacks( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that two device callback state updates do not proceed at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_multiple_callbacks( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, + wemo_entity, ) async def test_async_update_locked_callback_and_update( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that a callback and a state update request can't both happen at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_callback_and_update( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, - ) - - async def test_async_locked_update_with_exception( - self, hass, wemo_entity, pywemo_device - ): - """Test that the entity becomes unavailable when communication is lost.""" - await entity_test_helpers.test_async_locked_update_with_exception( - hass, wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.update_insight_params, - expected_state=self.EXPECTED_STATE_VALUE, - ) - - async def test_async_update_with_timeout_and_recovery( - self, hass, wemo_entity, pywemo_device - ): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - await entity_test_helpers.test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device, expected_state=self.EXPECTED_STATE_VALUE ) async def test_state_unavailable(self, hass, wemo_entity, pywemo_device): diff --git a/tests/components/wemo/test_switch.py b/tests/components/wemo/test_switch.py index 05151d38be8..1023498c792 100644 --- a/tests/components/wemo/test_switch.py +++ b/tests/components/wemo/test_switch.py @@ -1,11 +1,13 @@ """Tests for the Wemo switch entity.""" import pytest +from pywemo.exceptions import ActionException from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -30,12 +32,6 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) async def test_switch_registry_state_callback( @@ -78,3 +74,14 @@ async def test_switch_update_entity(hass, pywemo_registry, pywemo_device, wemo_e blocking=True, ) assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + + +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.on.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, SWITCH_DOMAIN + ) diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 38727a28424..e756e816a47 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -1,16 +1,28 @@ """Tests for wemo_device.py.""" -from unittest.mock import patch +import asyncio +from datetime import timedelta +from unittest.mock import call, patch +import async_timeout import pytest -from pywemo import PyWeMoException +from pywemo.exceptions import ActionException, PyWeMoException +from pywemo.subscribe import EVENT_TYPE_LONG_PRESS +from homeassistant import runner from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, wemo_device -from homeassistant.components.wemo.const import DOMAIN +from homeassistant.components.wemo.const import DOMAIN, WEMO_SUBSCRIPTION_EVENT +from homeassistant.core import callback from homeassistant.helpers import device_registry +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from .conftest import MOCK_HOST +from tests.common import async_fire_time_changed + +asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(True)) + @pytest.fixture def pywemo_model(): @@ -36,5 +48,157 @@ async def test_async_register_device_longpress_fails(hass, pywemo_device): dr = device_registry.async_get(hass) device_entries = list(dr.devices.values()) assert len(device_entries) == 1 - device_wrapper = wemo_device.async_get_device(hass, device_entries[0].id) - assert device_wrapper.supports_long_press is False + device = wemo_device.async_get_coordinator(hass, device_entries[0].id) + assert device.supports_long_press is False + + +async def test_long_press_event(hass, pywemo_registry, wemo_entity): + """Device fires a long press event.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + got_event = asyncio.Event() + event_data = {} + + @callback + def async_event_received(event): + nonlocal event_data + event_data = event.data + got_event.set() + + hass.bus.async_listen_once(WEMO_SUBSCRIPTION_EVENT, async_event_received) + + await hass.async_add_executor_job( + pywemo_registry.callbacks[device.wemo.name], + device.wemo, + EVENT_TYPE_LONG_PRESS, + "testing_params", + ) + + async with async_timeout.timeout(8): + await got_event.wait() + + assert event_data == { + "device_id": wemo_entity.device_id, + "name": device.wemo.name, + "params": "testing_params", + "type": EVENT_TYPE_LONG_PRESS, + "unique_id": device.wemo.serialnumber, + } + + +async def test_subscription_callback(hass, pywemo_registry, wemo_entity): + """Device processes a registry subscription callback.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = False + + got_callback = asyncio.Event() + + @callback + def async_received_callback(): + got_callback.set() + + device.async_add_listener(async_received_callback) + + await hass.async_add_executor_job( + pywemo_registry.callbacks[device.wemo.name], device.wemo, "", "" + ) + + async with async_timeout.timeout(8): + await got_callback.wait() + assert device.last_update_success + + +async def test_subscription_update_action_exception(hass, pywemo_device, wemo_entity): + """Device handles ActionException on get_state properly.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = True + + pywemo_device.subscription_update.return_value = False + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.side_effect = ActionException + await hass.async_add_executor_job( + device.subscription_callback, pywemo_device, "", "" + ) + await hass.async_block_till_done() + + pywemo_device.get_state.assert_called_once_with(True) + assert device.last_update_success is False + assert isinstance(device.last_exception, UpdateFailed) + + +async def test_subscription_update_exception(hass, pywemo_device, wemo_entity): + """Device handles Exception on get_state properly.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = True + + pywemo_device.subscription_update.return_value = False + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.side_effect = Exception + await hass.async_add_executor_job( + device.subscription_callback, pywemo_device, "", "" + ) + await hass.async_block_till_done() + + pywemo_device.get_state.assert_called_once_with(True) + assert device.last_update_success is False + assert isinstance(device.last_exception, Exception) + + +async def test_async_update_data_subscribed( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """No update happens when the device is subscribed.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + pywemo_registry.is_subscribed.return_value = True + pywemo_device.get_state.reset_mock() + await device._async_update_data() + pywemo_device.get_state.assert_not_called() + + +class TestInsight: + """Tests specific to the WeMo Insight device.""" + + @pytest.fixture + def pywemo_model(self): + """Pywemo Dimmer models use the light platform (WemoDimmer class).""" + return "Insight" + + @pytest.fixture(name="pywemo_device") + def pywemo_device_fixture(self, pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.insight_params = { + "currentpower": 1.0, + "todaymw": 200000000.0, + "state": 0, + "onfor": 0, + "ontoday": 0, + "ontotal": 0, + "powerthreshold": 0, + } + yield pywemo_device + + @pytest.mark.parametrize( + "subscribed,state,expected_calls", + [ + (False, 0, [call(), call(True), call(), call()]), + (False, 1, [call(), call(True), call(), call()]), + (True, 0, [call(), call(True), call(), call()]), + (True, 1, [call(), call(), call()]), + ], + ) + async def test_should_poll( + self, + hass, + subscribed, + state, + expected_calls, + wemo_entity, + pywemo_device, + pywemo_registry, + ): + """Validate the should_poll returns the correct value.""" + pywemo_registry.is_subscribed.return_value = subscribed + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.return_value = state + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + pywemo_device.get_state.assert_has_calls(expected_calls) diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index dbc1bf7c970..1d68879d510 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -126,7 +126,7 @@ async def test_color_palette_segment_change_state( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Some Other Palette", + ATTR_OPTION: "Icefire", }, blocking=True, ) @@ -134,7 +134,7 @@ async def test_color_palette_segment_change_state( assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with( segment_id=1, - palette="Some Other Palette", + palette="Icefire", ) @@ -195,7 +195,7 @@ async def test_color_palette_select_error( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Whatever", + ATTR_OPTION: "Icefire", }, blocking=True, ) @@ -206,7 +206,7 @@ async def test_color_palette_select_error( assert state.state == "Random Cycle" assert "Invalid response from API" in caplog.text assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, palette="Whatever") + mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") async def test_color_palette_select_connection_error( @@ -224,7 +224,7 @@ async def test_color_palette_select_connection_error( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Whatever", + ATTR_OPTION: "Icefire", }, blocking=True, ) @@ -235,7 +235,7 @@ async def test_color_palette_select_connection_error( assert state.state == STATE_UNAVAILABLE assert "Error communicating with API" in caplog.text assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, palette="Whatever") + mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") async def test_preset_unavailable_without_presets( diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py index bbb56efdeda..f1c96bc3ed8 100644 --- a/tests/components/wsdot/test_sensor.py +++ b/tests/components/wsdot/test_sensor.py @@ -35,6 +35,9 @@ async def test_setup(hass, requests_mock): def add_entities(new_entities, update_before_add=False): """Mock add entities.""" + for entity in new_entities: + entity.hass = hass + if update_before_add: for entity in new_entities: entity.update() diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 6f5709ec7cc..08900b1dfad 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -77,6 +77,30 @@ def mock_ssdp_no_yamaha(): yield +@pytest.fixture +def mock_valid_discovery_information(): + """Mock that the ssdp scanner returns a useful upnp description.""" + with patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[ + { + "ssdp_location": "http://127.0.0.1:9000/MediaRenderer/desc.xml", + "_host": "127.0.0.1", + } + ], + ): + yield + + +@pytest.fixture +def mock_empty_discovery_information(): + """Mock that the ssdp scanner returns no upnp description.""" + with patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[] + ): + yield + + # User Flows @@ -150,7 +174,9 @@ async def test_user_input_unknown_error(hass, mock_get_device_info_exception): assert result2["errors"] == {"base": "unknown"} -async def test_user_input_device_found(hass, mock_get_device_info_valid): +async def test_user_input_device_found( + hass, mock_get_device_info_valid, mock_valid_discovery_information +): """Test when user specifies an existing device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -167,6 +193,30 @@ async def test_user_input_device_found(hass, mock_get_device_info_valid): assert result2["data"] == { "host": "127.0.0.1", "serial": "1234567890", + "upnp_description": "http://127.0.0.1:9000/MediaRenderer/desc.xml", + } + + +async def test_user_input_device_found_no_ssdp( + hass, mock_get_device_info_valid, mock_empty_discovery_information +): + """Test when user specifies an existing device, which no discovery data are present for.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "127.0.0.1"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert isinstance(result2["result"], ConfigEntry) + assert result2["data"] == { + "host": "127.0.0.1", + "serial": "1234567890", + "upnp_description": "http://127.0.0.1:49154/MediaRenderer/desc.xml", } @@ -201,7 +251,9 @@ async def test_import_error(hass, mock_get_device_info_exception): assert result["errors"] == {"base": "unknown"} -async def test_import_device_successful(hass, mock_get_device_info_valid): +async def test_import_device_successful( + hass, mock_get_device_info_valid, mock_valid_discovery_information +): """Test when the device was imported successfully.""" config = {"platform": "yamaha_musiccast", "host": "127.0.0.1", "port": 5006} @@ -214,6 +266,7 @@ async def test_import_device_successful(hass, mock_get_device_info_valid): assert result["data"] == { "host": "127.0.0.1", "serial": "1234567890", + "upnp_description": "http://127.0.0.1:9000/MediaRenderer/desc.xml", } @@ -262,6 +315,7 @@ async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha): assert result2["data"] == { "host": "127.0.0.1", "serial": "1234567890", + "upnp_description": "http://127.0.0.1/desc.xml", } @@ -285,3 +339,4 @@ async def test_ssdp_discovery_existing_device_update(hass, mock_ssdp_yamaha): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" assert mock_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_entry.data["upnp_description"] == "http://127.0.0.1/desc.xml" diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 5725880f942..4a862fa13dd 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -1,9 +1,14 @@ """Tests for the Yeelight integration.""" -from unittest.mock import MagicMock, patch +import asyncio +from datetime import timedelta +from ipaddress import IPv4Address +from unittest.mock import AsyncMock, MagicMock, patch +from async_upnp_client.search import SSDPListener from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS +from homeassistant.components import yeelight as hass_yeelight from homeassistant.components.yeelight import ( CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -13,6 +18,9 @@ from homeassistant.components.yeelight import ( YeelightScanner, ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME +from homeassistant.core import callback + +FAIL_TO_BIND_IP = "1.2.3.4" IP_ADDRESS = "192.168.1.239" MODEL = "color" @@ -23,13 +31,16 @@ CAPABILITIES = { "id": ID, "model": MODEL, "fw_ver": FW_VER, + "location": f"yeelight://{IP_ADDRESS}", "support": "get_prop set_default set_power toggle set_bright start_cf stop_cf" " set_scene cron_add cron_get cron_del set_ct_abx set_rgb", "name": "", } NAME = "name" -UNIQUE_NAME = f"yeelight_{MODEL}_{ID}" +SHORT_ID = hex(int("0x000000000015243f", 16)) +UNIQUE_NAME = f"yeelight_{MODEL}_{SHORT_ID}" +UNIQUE_FRIENDLY_NAME = f"Yeelight {MODEL.title()} {SHORT_ID}" MODULE = "homeassistant.components.yeelight" MODULE_CONFIG_FLOW = f"{MODULE}.config_flow" @@ -79,31 +90,99 @@ YAML_CONFIGURATION = { CONFIG_ENTRY_DATA = {CONF_ID: ID} +class MockAsyncBulb: + """A mock for yeelight.aio.AsyncBulb.""" + + def __init__(self, model, bulb_type, cannot_connect): + """Init the mock.""" + self.model = model + self.bulb_type = bulb_type + self._async_callback = None + self._cannot_connect = cannot_connect + + async def async_listen(self, callback): + """Mock the listener.""" + if self._cannot_connect: + raise BulbException + self._async_callback = callback + + async def async_stop_listening(self): + """Drop the listener.""" + self._async_callback = None + + def set_capabilities(self, capabilities): + """Mock setting capabilities.""" + self.capabilities = capabilities + + def _mocked_bulb(cannot_connect=False): - bulb = MagicMock() - type(bulb).get_capabilities = MagicMock( - return_value=None if cannot_connect else CAPABILITIES + bulb = MockAsyncBulb(MODEL, BulbType.Color, cannot_connect) + type(bulb).async_get_properties = AsyncMock( + side_effect=BulbException if cannot_connect else None ) type(bulb).get_properties = MagicMock( side_effect=BulbException if cannot_connect else None ) type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) - - bulb.capabilities = CAPABILITIES - bulb.model = MODEL - bulb.bulb_type = BulbType.Color - bulb.last_properties = PROPERTIES + bulb.capabilities = CAPABILITIES.copy() + bulb.last_properties = PROPERTIES.copy() bulb.music_mode = False - + bulb.async_get_properties = AsyncMock() + bulb.async_update = AsyncMock() + bulb.async_turn_on = AsyncMock() + bulb.async_turn_off = AsyncMock() + bulb.async_set_brightness = AsyncMock() + bulb.async_set_color_temp = AsyncMock() + bulb.async_set_hsv = AsyncMock() + bulb.async_set_rgb = AsyncMock() + bulb.async_start_flow = AsyncMock() + bulb.async_stop_flow = AsyncMock() + bulb.async_set_power_mode = AsyncMock() + bulb.async_set_scene = AsyncMock() + bulb.async_set_default = AsyncMock() + bulb.start_music = MagicMock() return bulb -def _patch_discovery(prefix, no_device=False): +def _patched_ssdp_listener(info, *args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + if kwargs["source_ip"] == IPv4Address(FAIL_TO_BIND_IP): + raise OSError + await listener.async_connect_callback() + + @callback + def _async_search(*_): + if info: + asyncio.create_task(listener.async_callback(info)) + + listener.async_start = _async_callback + listener.async_search = _async_search + return listener + + +def _patch_discovery(no_device=False, capabilities=None): YeelightScanner._scanner = None # Clear class scanner to reset hass - def _mocked_discovery(timeout=2, interface=False): - if no_device: - return [] - return [{"ip": IP_ADDRESS, "port": 55443, "capabilities": CAPABILITIES}] + def _generate_fake_ssdp_listener(*args, **kwargs): + return _patched_ssdp_listener( + None if no_device else capabilities or CAPABILITIES, + *args, + **kwargs, + ) - return patch(f"{prefix}.discover_bulbs", side_effect=_mocked_discovery) + return patch( + "homeassistant.components.yeelight.SSDPListener", + new=_generate_fake_ssdp_listener, + ) + + +def _patch_discovery_interval(): + return patch.object( + hass_yeelight, "DISCOVERY_SEARCH_INTERVAL", timedelta(seconds=0) + ) + + +def _patch_discovery_timeout(): + return patch.object(hass_yeelight, "DISCOVERY_TIMEOUT", 0.0001) diff --git a/tests/components/yeelight/test_binary_sensor.py b/tests/components/yeelight/test_binary_sensor.py index f716469fc9a..350c289f5b5 100644 --- a/tests/components/yeelight/test_binary_sensor.py +++ b/tests/components/yeelight/test_binary_sensor.py @@ -6,7 +6,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component from homeassistant.setup import async_setup_component -from . import MODULE, NAME, PROPERTIES, YAML_CONFIGURATION, _mocked_bulb +from . import ( + MODULE, + NAME, + PROPERTIES, + YAML_CONFIGURATION, + _mocked_bulb, + _patch_discovery, +) ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" @@ -14,9 +21,7 @@ ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" async def test_nightlight(hass: HomeAssistant): """Test nightlight sensor.""" mocked_bulb = _mocked_bulb() - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch( - f"{MODULE}.config_flow.yeelight.Bulb", return_value=mocked_bulb - ): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) await hass.async_block_till_done() diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 8994c8e3360..6bc3ba68275 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Yeelight config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -19,20 +19,24 @@ from homeassistant.components.yeelight import ( DOMAIN, NIGHTLIGHT_SWITCH_TYPE_LIGHT, ) -from homeassistant.components.yeelight.config_flow import CannotConnect +from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM from . import ( + CAPABILITIES, ID, IP_ADDRESS, + MODEL, MODULE, MODULE_CONFIG_FLOW, NAME, - UNIQUE_NAME, + UNIQUE_FRIENDLY_NAME, _mocked_bulb, _patch_discovery, + _patch_discovery_interval, + _patch_discovery_timeout, ) from tests.common import MockConfigEntry @@ -48,28 +52,43 @@ DEFAULT_CONFIG = { async def test_discovery(hass: HomeAssistant): """Test setting up discovery.""" - 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_discovery_interval(): + 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(f"{MODULE_CONFIG_FLOW}.yeelight"): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result2["type"] == "form" - assert result2["step_id"] == "pick_device" - assert not result2["errors"] + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] - with patch(f"{MODULE}.async_setup", return_value=True) as mock_setup, patch( + # 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"], {}) + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_discovery_interval(), 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: ID} ) assert result3["type"] == "create_entry" - assert result3["title"] == UNIQUE_NAME - assert result3["data"] == {CONF_ID: ID} + assert result3["title"] == UNIQUE_FRIENDLY_NAME + assert result3["data"] == {CONF_ID: ID, CONF_HOST: IP_ADDRESS, CONF_MODEL: MODEL} await hass.async_block_till_done() mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -82,7 +101,83 @@ async def test_discovery(hass: HomeAssistant): assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(f"{MODULE_CONFIG_FLOW}.yeelight"): + with _patch_discovery(), _patch_discovery_interval(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + 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_ID: "0x000000000099999", CONF_HOST: "4.4.4.4"} + ) + config_entry.add_to_hass(hass) + alternate_bulb = _mocked_bulb() + alternate_bulb.capabilities["id"] = "0x000000000099999" + alternate_bulb.capabilities["location"] = "yeelight://4.4.4.4" + + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=alternate_bulb): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + 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_discovery_interval(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + 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_discovery_interval(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb() + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DEVICE: ID} + ) + assert result3["type"] == "create_entry" + assert result3["title"] == UNIQUE_FRIENDLY_NAME + assert result3["data"] == { + CONF_ID: ID, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + } + await hass.async_block_till_done() + await hass.async_block_till_done() + + # 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_discovery_interval(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result2["type"] == "abort" assert result2["reason"] == "no_devices_found" @@ -94,7 +189,9 @@ async def test_discovery_no_device(hass: HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with _patch_discovery(f"{MODULE_CONFIG_FLOW}.yeelight", no_device=True): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result2["type"] == "abort" @@ -114,26 +211,27 @@ async def test_import(hass: HomeAssistant): # Cannot connect mocked_bulb = _mocked_bulb(cannot_connect=True) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _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_IMPORT}, data=config ) - type(mocked_bulb).get_capabilities.assert_called_once() - type(mocked_bulb).get_properties.assert_called_once() assert result["type"] == "abort" assert result["reason"] == "cannot_connect" # Success mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch( - f"{MODULE}.async_setup", return_value=True - ) as mock_setup, patch( + with _patch_discovery(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ), 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 ) - type(mocked_bulb).get_capabilities.assert_called_once() assert result["type"] == "create_entry" assert result["title"] == DEFAULT_NAME assert result["data"] == { @@ -150,7 +248,9 @@ async def test_import(hass: HomeAssistant): # Duplicate mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config ) @@ -169,7 +269,11 @@ async def test_manual(hass: HomeAssistant): # Cannot connect (timeout) mocked_bulb = _mocked_bulb(cannot_connect=True) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) @@ -178,8 +282,11 @@ async def test_manual(hass: HomeAssistant): assert result2["errors"] == {"base": "cannot_connect"} # Cannot connect (error) - type(mocked_bulb).get_capabilities = MagicMock(side_effect=OSError) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) @@ -187,23 +294,33 @@ async def test_manual(hass: HomeAssistant): # Success mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch( - f"{MODULE}.async_setup", return_value=True - ), patch(f"{MODULE}.async_setup_entry", return_value=True): + with _patch_discovery(), _patch_discovery_timeout(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ), 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"] == "color 0x000000000015243f" - assert result4["data"] == {CONF_HOST: IP_ADDRESS} + assert result4["title"] == "Color 0x15243f" + assert result4["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } # Duplicate result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) @@ -219,13 +336,13 @@ async def test_options(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(), 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() config = { CONF_NAME: NAME, - CONF_MODEL: "", + CONF_MODEL: MODEL, CONF_TRANSITION: DEFAULT_TRANSITION, CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC, CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE, @@ -241,7 +358,7 @@ async def test_options(hass: HomeAssistant): config[CONF_NIGHTLIGHT_SWITCH] = True user_input = {**config} user_input.pop(CONF_NAME) - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input ) @@ -262,17 +379,24 @@ async def test_manual_no_capabilities(hass: HomeAssistant): assert not result["errors"] mocked_bulb = _mocked_bulb() - type(mocked_bulb).get_capabilities = MagicMock(return_value=None) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch( + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ), patch( f"{MODULE}.async_setup", return_value=True - ), patch(f"{MODULE}.async_setup_entry", 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} ) - type(mocked_bulb).get_capabilities.assert_called_once() - type(mocked_bulb).get_properties.assert_called_once() assert result["type"] == "create_entry" - assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: None, + CONF_MODEL: MODEL_UNKNOWN, + } async def test_discovered_by_homekit_and_dhcp(hass): @@ -280,39 +404,53 @@ async def test_discovered_by_homekit_and_dhcp(hass): await setup.async_setup_component(hass, "persistent_notification", {}) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=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_HOMEKIT}, - data={"host": "1.2.3.4", "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + data={"host": IP_ADDRESS, "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, ) + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.4", "macaddress": "aa:bb:cc:dd:ee:ff"}, + data={"ip": IP_ADDRESS, "macaddress": "aa:bb:cc:dd:ee:ff"}, ) + await hass.async_block_till_done() assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_in_progress" - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data={"ip": "1.2.3.4", "macaddress": "00:00:00:00:00:00"}, + 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(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", side_effect=CannotConnect): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", side_effect=CannotConnect + ): 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" @@ -335,19 +473,31 @@ async def test_discovered_by_dhcp_or_homekit(hass, source, data): await setup.async_setup_component(hass, "persistent_notification", {}) mocked_bulb = _mocked_bulb() - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=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": source}, data=data ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None - with patch(f"{MODULE}.async_setup", return_value=True) as mock_async_setup, patch( + 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"} + assert result2["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } assert mock_async_setup.called assert mock_async_setup_entry.called @@ -370,10 +520,59 @@ async def test_discovered_by_dhcp_or_homekit_failed_to_get_id(hass, source, data await setup.async_setup_component(hass, "persistent_notification", {}) mocked_bulb = _mocked_bulb() - type(mocked_bulb).get_capabilities = MagicMock(return_value=None) - with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb): + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "cannot_connect" + + +async def test_discovered_ssdp(hass): + """Test we can setup when discovered from ssdp.""" + 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_SSDP}, data=CAPABILITIES + ) + 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_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 2d1113d1896..4b3ac8e0e83 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -1,9 +1,12 @@ """Test Yeelight.""" -from unittest.mock import MagicMock, patch +from datetime import timedelta +from unittest.mock import AsyncMock, patch -from yeelight import BulbType +from yeelight import BulbException, BulbType +from yeelight.aio import KEY_CONNECTED from homeassistant.components.yeelight import ( + CONF_MODEL, CONF_NIGHTLIGHT_SWITCH, CONF_NIGHTLIGHT_SWITCH_TYPE, DATA_CONFIG_ENTRIES, @@ -22,24 +25,28 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import ( - CAPABILITIES, CONFIG_ENTRY_DATA, ENTITY_AMBILIGHT, ENTITY_BINARY_SENSOR, ENTITY_BINARY_SENSOR_TEMPLATE, ENTITY_LIGHT, ENTITY_NIGHTLIGHT, + FAIL_TO_BIND_IP, ID, IP_ADDRESS, + MODEL, MODULE, - MODULE_CONFIG_FLOW, + SHORT_ID, _mocked_bulb, _patch_discovery, + _patch_discovery_interval, + _patch_discovery_timeout, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_ip_changes_fallback_discovery(hass: HomeAssistant): @@ -51,34 +58,41 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant): mocked_bulb = _mocked_bulb(True) mocked_bulb.bulb_type = BulbType.WhiteTempMood - mocked_bulb.get_capabilities = MagicMock( - side_effect=[OSError, CAPABILITIES, CAPABILITIES] - ) + mocked_bulb.async_listen = AsyncMock(side_effect=[BulbException, None, None, None]) - _discovered_devices = [{"capabilities": CAPABILITIES, "ip": IP_ADDRESS}] - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch( - f"{MODULE}.discover_bulbs", return_value=_discovered_devices - ): + 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() + await hass.async_block_till_done() binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( - f"yeelight_color_{ID}" + f"yeelight_color_{SHORT_ID}" ) - entity_registry = er.async_get(hass) - assert entity_registry.async_get(binary_sensor_entity_id) is None - await hass.async_block_till_done() + type(mocked_bulb).async_get_properties = AsyncMock(None) - type(mocked_bulb).get_properties = MagicMock(None) - - hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE].update() + 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 entity_registry.async_get(binary_sensor_entity_id) is not None + async def test_ip_changes_id_missing_cannot_fallback(hass: HomeAssistant): """Test Yeelight ip changes and we fallback to discovery.""" @@ -87,11 +101,9 @@ async def test_ip_changes_id_missing_cannot_fallback(hass: HomeAssistant): mocked_bulb = _mocked_bulb(True) mocked_bulb.bulb_type = BulbType.WhiteTempMood - mocked_bulb.get_capabilities = MagicMock( - side_effect=[OSError, CAPABILITIES, CAPABILITIES] - ) + mocked_bulb.async_listen = AsyncMock(side_effect=[BulbException, None, None, None]) - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -104,7 +116,7 @@ async def test_setup_discovery(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(), 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() @@ -123,13 +135,112 @@ async def test_setup_discovery(hass: HomeAssistant): assert hass.states.get(ENTITY_LIGHT) is None +_ADAPTERS_WITH_MANUAL_CONFIG = [ + { + "auto": True, + "index": 2, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, +] + + +async def test_setup_discovery_with_manually_configured_network_adapter( + hass: HomeAssistant, +): + """Test setting up Yeelight by discovery with a manually configured network adapter.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_BINARY_SENSOR) is not None + assert hass.states.get(ENTITY_LIGHT) is not None + + # Unload + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == STATE_UNAVAILABLE + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE + + # Remove + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_BINARY_SENSOR) is None + assert hass.states.get(ENTITY_LIGHT) is None + + +_ADAPTERS_WITH_MANUAL_CONFIG_ONE_FAILING = [ + { + "auto": True, + "index": 1, + "default": False, + "enabled": True, + "ipv4": [{"address": FAIL_TO_BIND_IP, "network_prefix": 23}], + "ipv6": [], + "name": "eth0", + }, + { + "auto": True, + "index": 2, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, +] + + +async def test_setup_discovery_with_manually_configured_network_adapter_one_fails( + hass: HomeAssistant, caplog +): + """Test setting up Yeelight by discovery with a manually configured network adapter with one that fails to bind.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + with _patch_discovery(), patch( + f"{MODULE}.AsyncBulb", return_value=mocked_bulb + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG_ONE_FAILING, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_BINARY_SENSOR) is not None + assert hass.states.get(ENTITY_LIGHT) is not None + + # Unload + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == STATE_UNAVAILABLE + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE + + # Remove + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_BINARY_SENSOR) is None + assert hass.states.get(ENTITY_LIGHT) is None + + assert f"Failed to setup listener for {FAIL_TO_BIND_IP}" in caplog.text + + async def test_setup_import(hass: HomeAssistant): """Test import from yaml.""" mocked_bulb = _mocked_bulb() name = "yeelight" - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch( - f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb - ): + with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): assert await async_setup_component( hass, DOMAIN, @@ -149,6 +260,9 @@ async def test_setup_import(hass: HomeAssistant): assert hass.states.get(f"binary_sensor.{name}_nightlight") is not None assert hass.states.get(f"light.{name}") is not None assert hass.states.get(f"light.{name}_nightlight") is not None + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.unique_id == "0x000000000015243f" + assert entry.data[CONF_ID] == "0x000000000015243f" async def test_unique_ids_device(hass: HomeAssistant): @@ -162,7 +276,7 @@ async def test_unique_ids_device(hass: HomeAssistant): mocked_bulb = _mocked_bulb() mocked_bulb.bulb_type = BulbType.WhiteTempMood - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(), 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() @@ -186,7 +300,7 @@ async def test_unique_ids_entry(hass: HomeAssistant): mocked_bulb = _mocked_bulb() mocked_bulb.bulb_type = BulbType.WhiteTempMood - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + with _patch_discovery(), 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() @@ -216,24 +330,114 @@ async def test_bulb_off_while_adding_in_ha(hass: HomeAssistant): mocked_bulb = _mocked_bulb(True) mocked_bulb.bulb_type = BulbType.WhiteTempMood - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch( - f"{MODULE}.config_flow.yeelight.Bulb", return_value=mocked_bulb - ): + 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.async_block_till_done() - binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( - IP_ADDRESS.replace(".", "_") + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_async_listen_error_late_discovery(hass, caplog): + """Test the async listen error.""" + 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(), 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 + assert "Failed to connect to bulb at" in caplog.text + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + caplog.clear() + + with _patch_discovery(), 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 "Failed to connect to bulb at" not in caplog.text + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options[CONF_MODEL] == MODEL + + +async def test_async_listen_error_has_host_with_id(hass: HomeAssistant): + """Test the async listen error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_ID: ID, CONF_HOST: "127.0.0.1"} ) - entity_registry = er.async_get(hass) - assert entity_registry.async_get(binary_sensor_entity_id) is None + config_entry.add_to_hass(hass) - type(mocked_bulb).get_capabilities = MagicMock(CAPABILITIES) - type(mocked_bulb).get_properties = MagicMock(None) + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb(cannot_connect=True) + ): + await hass.config_entries.async_setup(config_entry.entry_id) - hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE].update() - await hass.async_block_till_done() - 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 + +async def test_async_listen_error_has_host_without_id(hass: HomeAssistant): + """Test the async listen error but no id.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}) + config_entry.add_to_hass(hass) + + with _patch_discovery( + no_device=True + ), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb(cannot_connect=True) + ): + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_async_setup_with_missing_id(hass: HomeAssistant): + """Test that setting adds the missing CONF_ID from unique_id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ID, + data={CONF_HOST: "127.0.0.1"}, + options={CONF_NAME: "Test name"}, + ) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb(cannot_connect=True) + ): + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data[CONF_ID] == ID + + +async def test_connection_dropped_resyncs_properties(hass: HomeAssistant): + """Test handling a connection drop results in a property resync.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ID, + data={CONF_HOST: "127.0.0.1"}, + options={CONF_NAME: "Test name"}, + ) + config_entry.add_to_hass(hass) + mocked_bulb = _mocked_bulb() + + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), 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 len(mocked_bulb.async_get_properties.mock_calls) == 1 + mocked_bulb._async_callback({KEY_CONNECTED: False}) + 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() + 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 9283514cb70..030f6a54cea 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -1,6 +1,6 @@ """Test the Yeelight light.""" import logging -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch from yeelight import ( BulbException, @@ -19,6 +19,7 @@ from yeelight.main import _MODEL_SPECS from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, @@ -95,15 +96,17 @@ from homeassistant.util.color import ( ) from . import ( + CAPABILITIES, ENTITY_LIGHT, ENTITY_NIGHTLIGHT, IP_ADDRESS, MODULE, NAME, PROPERTIES, - UNIQUE_NAME, + UNIQUE_FRIENDLY_NAME, _mocked_bulb, _patch_discovery, + _patch_discovery_interval, ) from tests.common import MockConfigEntry @@ -131,7 +134,9 @@ async def test_services(hass: HomeAssistant, caplog): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", 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() @@ -146,8 +151,11 @@ async def test_services(hass: HomeAssistant, caplog): err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) # success - mocked_method = MagicMock() - setattr(type(mocked_bulb), method, mocked_method) + if method.startswith("async_"): + mocked_method = AsyncMock() + else: + mocked_method = MagicMock() + setattr(mocked_bulb, method, mocked_method) await hass.services.async_call(domain, service, data, blocking=True) if payload is None: mocked_method.assert_called_once() @@ -161,8 +169,11 @@ async def test_services(hass: HomeAssistant, caplog): # failure if failure_side_effect: - mocked_method = MagicMock(side_effect=failure_side_effect) - setattr(type(mocked_bulb), method, mocked_method) + if method.startswith("async_"): + mocked_method = AsyncMock(side_effect=failure_side_effect) + 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]) @@ -173,6 +184,7 @@ async def test_services(hass: HomeAssistant, caplog): brightness = 100 rgb_color = (0, 128, 255) transition = 2 + mocked_bulb.last_properties["power"] = "off" await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -186,30 +198,30 @@ async def test_services(hass: HomeAssistant, caplog): }, blocking=True, ) - mocked_bulb.turn_on.assert_called_once_with( + mocked_bulb.async_turn_on.assert_called_once_with( duration=transition * 1000, light_type=LightType.Main, power_mode=PowerMode.NORMAL, ) - mocked_bulb.turn_on.reset_mock() + mocked_bulb.async_turn_on.reset_mock() mocked_bulb.start_music.assert_called_once() mocked_bulb.start_music.reset_mock() - mocked_bulb.set_brightness.assert_called_once_with( + mocked_bulb.async_set_brightness.assert_called_once_with( brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_brightness.reset_mock() - mocked_bulb.set_color_temp.assert_not_called() - mocked_bulb.set_color_temp.reset_mock() - mocked_bulb.set_hsv.assert_not_called() - mocked_bulb.set_hsv.reset_mock() - mocked_bulb.set_rgb.assert_called_once_with( + mocked_bulb.async_set_brightness.reset_mock() + mocked_bulb.async_set_color_temp.assert_not_called() + mocked_bulb.async_set_color_temp.reset_mock() + mocked_bulb.async_set_hsv.assert_not_called() + mocked_bulb.async_set_hsv.reset_mock() + mocked_bulb.async_set_rgb.assert_called_once_with( *rgb_color, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_rgb.reset_mock() - mocked_bulb.start_flow.assert_called_once() # flash - mocked_bulb.start_flow.reset_mock() - mocked_bulb.stop_flow.assert_called_once_with(light_type=LightType.Main) - mocked_bulb.stop_flow.reset_mock() + mocked_bulb.async_set_rgb.reset_mock() + mocked_bulb.async_start_flow.assert_called_once() # flash + mocked_bulb.async_start_flow.reset_mock() + mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + mocked_bulb.async_stop_flow.reset_mock() # turn_on hs_color brightness = 100 @@ -228,35 +240,36 @@ async def test_services(hass: HomeAssistant, caplog): }, blocking=True, ) - mocked_bulb.turn_on.assert_called_once_with( + mocked_bulb.async_turn_on.assert_called_once_with( duration=transition * 1000, light_type=LightType.Main, power_mode=PowerMode.NORMAL, ) - mocked_bulb.turn_on.reset_mock() + mocked_bulb.async_turn_on.reset_mock() mocked_bulb.start_music.assert_called_once() mocked_bulb.start_music.reset_mock() - mocked_bulb.set_brightness.assert_called_once_with( + mocked_bulb.async_set_brightness.assert_called_once_with( brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_brightness.reset_mock() - mocked_bulb.set_color_temp.assert_not_called() - mocked_bulb.set_color_temp.reset_mock() - mocked_bulb.set_hsv.assert_called_once_with( + mocked_bulb.async_set_brightness.reset_mock() + mocked_bulb.async_set_color_temp.assert_not_called() + mocked_bulb.async_set_color_temp.reset_mock() + mocked_bulb.async_set_hsv.assert_called_once_with( *hs_color, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_hsv.reset_mock() - mocked_bulb.set_rgb.assert_not_called() - mocked_bulb.set_rgb.reset_mock() - mocked_bulb.start_flow.assert_called_once() # flash - mocked_bulb.start_flow.reset_mock() - mocked_bulb.stop_flow.assert_called_once_with(light_type=LightType.Main) - mocked_bulb.stop_flow.reset_mock() + mocked_bulb.async_set_hsv.reset_mock() + mocked_bulb.async_set_rgb.assert_not_called() + mocked_bulb.async_set_rgb.reset_mock() + mocked_bulb.async_start_flow.assert_called_once() # flash + mocked_bulb.async_start_flow.reset_mock() + mocked_bulb.async_stop_flow.assert_called_once_with(light_type=LightType.Main) + mocked_bulb.async_stop_flow.reset_mock() # turn_on color_temp brightness = 100 color_temp = 200 transition = 1 + mocked_bulb.last_properties["power"] = "off" await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -270,31 +283,32 @@ async def test_services(hass: HomeAssistant, caplog): }, blocking=True, ) - mocked_bulb.turn_on.assert_called_once_with( + mocked_bulb.async_turn_on.assert_called_once_with( duration=transition * 1000, light_type=LightType.Main, power_mode=PowerMode.NORMAL, ) - mocked_bulb.turn_on.reset_mock() + mocked_bulb.async_turn_on.reset_mock() mocked_bulb.start_music.assert_called_once() - mocked_bulb.set_brightness.assert_called_once_with( + mocked_bulb.async_set_brightness.assert_called_once_with( brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) - mocked_bulb.set_color_temp.assert_called_once_with( + 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.set_hsv.assert_not_called() - mocked_bulb.set_rgb.assert_not_called() - mocked_bulb.start_flow.assert_called_once() # flash - mocked_bulb.stop_flow.assert_called_once_with(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) + mocked_bulb.last_properties["power"] = "off" # turn_on nightlight await _async_test_service( SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_NIGHTLIGHT}, - "turn_on", + "async_turn_on", payload={ "duration": DEFAULT_TRANSITION, "light_type": LightType.Main, @@ -303,11 +317,12 @@ async def test_services(hass: HomeAssistant, caplog): domain="light", ) + mocked_bulb.last_properties["power"] = "on" # turn_off await _async_test_service( SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITION: transition}, - "turn_off", + "async_turn_off", domain="light", payload={"duration": transition * 1000, "light_type": LightType.Main}, ) @@ -317,7 +332,7 @@ async def test_services(hass: HomeAssistant, caplog): await _async_test_service( SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MODE: "rgb"}, - "set_power_mode", + "async_set_power_mode", [PowerMode[mode.upper()]], ) @@ -328,7 +343,7 @@ async def test_services(hass: HomeAssistant, caplog): ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], }, - "start_flow", + "async_start_flow", ) # set_color_scene @@ -339,7 +354,7 @@ async def test_services(hass: HomeAssistant, caplog): ATTR_RGB_COLOR: [10, 20, 30], ATTR_BRIGHTNESS: 50, }, - "set_scene", + "async_set_scene", [SceneClass.COLOR, 10, 20, 30, 50], ) @@ -347,7 +362,7 @@ async def test_services(hass: HomeAssistant, caplog): await _async_test_service( SERVICE_SET_HSV_SCENE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_HS_COLOR: [180, 50], ATTR_BRIGHTNESS: 50}, - "set_scene", + "async_set_scene", [SceneClass.HSV, 180, 50, 50], ) @@ -355,7 +370,7 @@ async def test_services(hass: HomeAssistant, caplog): await _async_test_service( SERVICE_SET_COLOR_TEMP_SCENE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_KELVIN: 4000, ATTR_BRIGHTNESS: 50}, - "set_scene", + "async_set_scene", [SceneClass.CT, 4000, 50], ) @@ -366,14 +381,14 @@ async def test_services(hass: HomeAssistant, caplog): ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], }, - "set_scene", + "async_set_scene", ) # set_auto_delay_off_scene await _async_test_service( SERVICE_SET_AUTO_DELAY_OFF_SCENE, {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MINUTES: 1, ATTR_BRIGHTNESS: 50}, - "set_scene", + "async_set_scene", [SceneClass.AUTO_DELAY_OFF, 50, 1], ) @@ -401,6 +416,7 @@ async def test_services(hass: HomeAssistant, caplog): failure_side_effect=None, ) # 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) @@ -415,6 +431,115 @@ async def test_services(hass: HomeAssistant, caplog): ) +async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant): + """Ensure we suppress state changes that will increase the rate limit when there is no change.""" + 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() + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_HS_COLOR: (PROPERTIES["hue"], PROPERTIES["sat"]), + }, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + + mocked_bulb.last_properties["color_mode"] = 1 + rgb = int(PROPERTIES["rgb"]) + blue = rgb & 0xFF + green = (rgb >> 8) & 0xFF + red = (rgb >> 16) & 0xFF + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_RGB_COLOR: (red, green, blue)}, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + mocked_bulb.async_set_rgb.reset_mock() + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS_PCT: PROPERTIES["current_brightness"], + }, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + # Should call for the color mode change + assert mocked_bulb.async_set_color_temp.mock_calls == [ + call(4000, duration=350, light_type=ANY) + ] + assert mocked_bulb.async_set_brightness.mock_calls == [] + mocked_bulb.async_set_color_temp.reset_mock() + + mocked_bulb.last_properties["color_mode"] = 2 + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + + mocked_bulb.last_properties["color_mode"] = 3 + # This last change should generate a call even though + # the color mode is the same since the HSV has changed + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_HS_COLOR: (5, 5)}, + blocking=True, + ) + assert mocked_bulb.async_set_hsv.mock_calls == [ + call(5.0, 5.0, duration=350, light_type=ANY) + ] + assert mocked_bulb.async_set_rgb.mock_calls == [] + assert mocked_bulb.async_set_color_temp.mock_calls == [] + assert mocked_bulb.async_set_brightness.mock_calls == [] + + async def test_device_types(hass: HomeAssistant, caplog): """Test different device types.""" mocked_bulb = _mocked_bulb() @@ -424,8 +549,11 @@ async def test_device_types(hass: HomeAssistant, caplog): mocked_bulb.last_properties = properties async def _async_setup(config_entry): - with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): - await hass.config_entries.async_setup(config_entry.entry_id) + 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() async def _async_test( @@ -433,7 +561,7 @@ async def test_device_types(hass: HomeAssistant, caplog): model, target_properties, nightlight_properties=None, - name=UNIQUE_NAME, + name=UNIQUE_FRIENDLY_NAME, entity_id=ENTITY_LIGHT, ): config_entry = MockConfigEntry( @@ -447,6 +575,7 @@ async def test_device_types(hass: HomeAssistant, caplog): await _async_setup(config_entry) state = hass.states.get(entity_id) + assert state.state == "on" target_properties["friendly_name"] = name target_properties["flowing"] = False @@ -471,7 +600,7 @@ async def test_device_types(hass: HomeAssistant, caplog): 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["friendly_name"] = f"{name} Nightlight" nightlight_properties["icon"] = "mdi:weather-night" nightlight_properties["flowing"] = False nightlight_properties["night_light"] = True @@ -481,6 +610,7 @@ async def test_device_types(hass: HomeAssistant, caplog): 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) @@ -544,6 +674,9 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], + "hs_color": (26.812, 34.87), + "rgb_color": (255, 205, 166), + "xy_color": (0.421, 0.364), }, { "supported_features": 0, @@ -708,6 +841,9 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], + "hs_color": (26.812, 34.87), + "rgb_color": (255, 205, 166), + "xy_color": (0.421, 0.364), }, { "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, @@ -741,6 +877,9 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_temp": ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], + "hs_color": (26.812, 34.87), + "rgb_color": (255, 205, 166), + "xy_color": (0.421, 0.364), }, { "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, @@ -764,8 +903,11 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_temp": bg_ct, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], + "hs_color": (27.001, 19.243), + "rgb_color": (255, 228, 205), + "xy_color": (0.372, 0.35), }, - name=f"{UNIQUE_NAME} ambilight", + name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", ) @@ -786,7 +928,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - name=f"{UNIQUE_NAME} ambilight", + name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", ) @@ -807,7 +949,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, - name=f"{UNIQUE_NAME} ambilight", + name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", ) @@ -841,7 +983,9 @@ async def test_effects(hass: HomeAssistant): config_entry.add_to_hass(hass) mocked_bulb = _mocked_bulb() - with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", 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() @@ -850,8 +994,8 @@ async def test_effects(hass: HomeAssistant): ) == YEELIGHT_COLOR_EFFECT_LIST + ["mock_effect"] async def _async_test_effect(name, target=None, called=True): - mocked_start_flow = MagicMock() - type(mocked_bulb).start_flow = mocked_start_flow + async_mocked_start_flow = AsyncMock() + mocked_bulb.async_start_flow = async_mocked_start_flow await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -860,10 +1004,10 @@ async def test_effects(hass: HomeAssistant): ) if not called: return - mocked_start_flow.assert_called_once() + async_mocked_start_flow.assert_called_once() if target is None: return - args, _ = mocked_start_flow.call_args + args, _ = async_mocked_start_flow.call_args flow = args[0] assert flow.count == target.count assert flow.action == target.action @@ -979,3 +1123,94 @@ async def test_effects(hass: HomeAssistant): for name, target in effects.items(): await _async_test_effect(name, target) 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() + properties = {**PROPERTIES} + capabilities = {**CAPABILITIES} + 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 + main_light_entity_id = "light.yeelight_ceiling10_0x15243f" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}, + options={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}, + ) + config_entry.add_to_hass(hass) + with _patch_discovery(capabilities=capabilities), 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() + + state = hass.states.get(main_light_entity_id) + assert state.state == "on" + # bg_power off should not set the brightness to 0 + assert state.attributes[ATTR_BRIGHTNESS] == 128 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0db8f0f5227..d13bbc97547 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -39,7 +39,7 @@ def service_update_mock(ipv6, zeroconf, services, handlers, *, limit_service=Non handlers[0](zeroconf, service, f"_name.{service}", ServiceStateChange.Added) -def get_service_info_mock(service_type, name): +def get_service_info_mock(service_type, name, *args, **kwargs): """Return service info for get_service_info.""" return AsyncServiceInfo( service_type, @@ -799,7 +799,14 @@ async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zero hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert mock_zc.mock_calls[0] == call( - interfaces=[1, "192.168.1.5", "172.16.1.5", 3], ip_version=IPVersion.All + interfaces=[ + "2001:db8::", + "fe80::1234:5678:9abc:def0", + "192.168.1.5", + "172.16.1.5", + "fe80::dead:beef:dead:beef", + ], + ip_version=IPVersion.All, ) @@ -862,5 +869,19 @@ async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zero await hass.async_block_till_done() assert mock_zc.mock_calls[0] == call( - interfaces=["192.168.1.5", 1], ip_version=IPVersion.All + interfaces=["192.168.1.5", "fe80::dead:beef:dead:beef"], + ip_version=IPVersion.All, ) + + +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 = "" + with patch("homeassistant.components.zeroconf.HaZeroconf"): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + register_call = mock_async_zeroconf.async_register_service.mock_calls[-1] + info = register_call.args[0] + assert info.name == "Home._home-assistant._tcp.local." diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 16747980b15..732b7cf440d 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,18 +1,38 @@ """Tests for ZHA config flow.""" -import os from unittest.mock import AsyncMock, MagicMock, patch, sentinel import pytest import serial.tools.list_ports import zigpy.config +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant import setup +from homeassistant import config_entries, setup +from homeassistant.components.ssdp import ( + ATTR_SSDP_LOCATION, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, +) from homeassistant.components.zha import config_flow -from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, DOMAIN, RadioType -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.components.zha.core.const import ( + CONF_BAUDRATE, + CONF_FLOWCONTROL, + CONF_RADIO_TYPE, + DOMAIN, + RadioType, +) +from homeassistant.config_entries import ( + SOURCE_SSDP, + SOURCE_USB, + SOURCE_USER, + SOURCE_ZEROCONF, +) from homeassistant.const import CONF_SOURCE -from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) from tests.common import MockConfigEntry @@ -48,15 +68,255 @@ async def test_discovery(detect_mock, hass): assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "socket://192.168.1.200:6638" assert result["data"] == { - "device": { - "baudrate": 115200, - "flow_control": None, - "path": "socket://192.168.1.200:6638", + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + CONF_DEVICE_PATH: "socket://192.168.1.200:6638", }, CONF_RADIO_TYPE: "znp", } +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_zeroconf_ip_change(detect_mock, hass): + """Test zeroconf flow -- radio detected.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="tube_zb_gw_cc2652p2_poe", + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "socket://192.168.1.5:6638", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + } + }, + ) + entry.add_to_hass(hass) + + service_info = { + "host": "192.168.1.22", + "port": 6053, + "hostname": "tube_zb_gw_cc2652p2_poe.local.", + "properties": {"address": "tube_zb_gw_cc2652p2_poe.local"}, + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_DEVICE] == { + CONF_DEVICE_PATH: "socket://192.168.1.22:6638", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + } + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb(detect_mock, hass): + """Test usb flow -- radio detected.""" + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + with patch("homeassistant.components.zha.async_setup_entry"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert "zigbee radio" in result2["title"] + assert result2["data"] == { + "device": { + "baudrate": 115200, + "flow_control": None, + "path": "/dev/ttyZIGBEE", + }, + CONF_RADIO_TYPE: "znp", + } + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=False) +async def test_discovery_via_usb_no_radio(detect_mock, hass): + """Test usb flow -- no radio detected.""" + discovery_info = { + "device": "/dev/null", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + with patch("homeassistant.components.zha.async_setup_entry"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "usb_probe_failed" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_already_setup(detect_mock, hass): + """Test usb flow -- already setup.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) + + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_discovery_via_usb_path_changes(hass): + """Test usb flow already setup and the path changes.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AAAA:AAAA_1234_test_zigbee radio", + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB1", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + } + }, + ) + entry.add_to_hass(hass) + + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_DEVICE] == { + CONF_DEVICE_PATH: "/dev/ttyZIGBEE", + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: None, + } + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_already_discovered(detect_mock, hass): + """Test usb flow -- deconz discovered.""" + result = await hass.config_entries.flow.async_init( + "deconz", + data={ + ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", + ATTR_UPNP_MANUFACTURER_URL: "http://www.dresden-elektronik.de", + ATTR_UPNP_SERIAL: "0000000000000000", + }, + context={"source": SOURCE_SSDP}, + ) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "not_zha_device" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_already_setup(detect_mock, hass): + """Test usb flow -- deconz setup.""" + MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "not_zha_device" + + +@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) +async def test_discovery_via_usb_deconz_ignored(detect_mock, hass): + """Test usb flow -- deconz ignored.""" + MockConfigEntry( + domain="deconz", source=config_entries.SOURCE_IGNORE, data={} + ).add_to_hass(hass) + await hass.async_block_till_done() + discovery_info = { + "device": "/dev/ttyZIGBEE", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zigbee radio", + "manufacturer": "test", + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True) async def test_discovery_already_setup(detect_mock, hass): @@ -68,7 +328,9 @@ async def test_discovery_already_setup(detect_mock, hass): "properties": {"name": "tube_123456"}, } await setup.async_setup_component(hass, "persistent_notification", {}) - MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "zha", context={"source": SOURCE_ZEROCONF}, data=service_info @@ -174,7 +436,9 @@ async def test_pick_radio_flow(hass, radio_type): async def test_user_flow_existing_config_entry(hass): """Test if config entry already exists.""" - MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} @@ -218,6 +482,39 @@ async def test_probe_radios(xbee_probe, zigate_probe, deconz_probe, cc_probe, ha assert cc_probe.await_count == 1 +@patch("zigpy_cc.zigbee.application.ControllerApplication.probe", return_value=False) +@patch( + "zigpy_deconz.zigbee.application.ControllerApplication.probe", return_value=False +) +@patch( + "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): + """Test detect radios.""" + app_ctrl_cls = MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.probe = AsyncMock(side_efferct=(True, False)) + + p1 = patch( + "bellows.zigbee.application.ControllerApplication.probe", + return_value={ + zigpy.config.CONF_DEVICE_PATH: sentinel.usb_port, + "baudrate": 33840, + }, + ) + with p1 as probe_mock: + res = await config_flow.detect_radios("/dev/null") + assert probe_mock.await_count == 1 + assert res[CONF_RADIO_TYPE] == "ezsp" + assert zigpy.config.CONF_DEVICE in res + assert ( + res[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] + is sentinel.usb_port + ) + assert res[zigpy.config.CONF_DEVICE]["baudrate"] == 33840 + + @patch("bellows.zigbee.application.ControllerApplication.probe", return_value=False) async def test_user_port_config_fail(probe_mock, hass): """Test port config flow.""" @@ -263,50 +560,3 @@ async def test_user_port_config(probe_mock, hass): ) assert result["data"][CONF_RADIO_TYPE] == "ezsp" assert probe_mock.await_count == 1 - - -def test_get_serial_by_id_no_dir(): - """Test serial by id conversion if there's no /dev/serial/by-id.""" - p1 = patch("os.path.isdir", MagicMock(return_value=False)) - p2 = patch("os.scandir") - with p1 as is_dir_mock, p2 as scan_mock: - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.path - assert is_dir_mock.call_count == 1 - assert scan_mock.call_count == 0 - - -def test_get_serial_by_id(): - """Test serial by id conversion.""" - p1 = patch("os.path.isdir", MagicMock(return_value=True)) - p2 = patch("os.scandir") - - def _realpath(path): - if path is sentinel.matched_link: - return sentinel.path - return sentinel.serial_link_path - - p3 = patch("os.path.realpath", side_effect=_realpath) - with p1 as is_dir_mock, p2 as scan_mock, p3: - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.path - assert is_dir_mock.call_count == 1 - assert scan_mock.call_count == 1 - - entry1 = MagicMock(spec_set=os.DirEntry) - entry1.is_symlink.return_value = True - entry1.path = sentinel.some_path - - entry2 = MagicMock(spec_set=os.DirEntry) - entry2.is_symlink.return_value = False - entry2.path = sentinel.other_path - - entry3 = MagicMock(spec_set=os.DirEntry) - entry3.is_symlink.return_value = True - entry3.path = sentinel.matched_link - - scan_mock.return_value = [entry1, entry2, entry3] - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.matched_link - assert is_dir_mock.call_count == 2 - assert scan_mock.call_count == 2 diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 4a777fcebb6..49fa11de26c 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -8,14 +8,11 @@ import zigpy.zcl.clusters.security as security import zigpy.zcl.foundation as zcl_f import homeassistant.components.automation as automation -from homeassistant.components.device_automation import ( - _async_get_device_automations as async_get_device_automations, -) from homeassistant.components.zha import DOMAIN from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from tests.common import async_mock_service, mock_coro +from tests.common import async_get_device_automations, async_mock_service, mock_coro from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 SHORT_PRESS = "remote_button_short_press" diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 0408f164049..915fc77462b 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -297,12 +297,12 @@ async def async_test_on_off_from_light(hass, cluster, entity_id): """Test on off functionality from the light.""" # turn on at light await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 3}) - await hass.async_block_till_done() + await async_wait_for_updates(hass) assert hass.states.get(entity_id).state == STATE_ON # turn off at light await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 3}) - await hass.async_block_till_done() + await async_wait_for_updates(hass) assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/zwave/test_sensor.py b/tests/components/zwave/test_sensor.py index ae0fa44ed8c..4f995131d15 100644 --- a/tests/components/zwave/test_sensor.py +++ b/tests/components/zwave/test_sensor.py @@ -70,8 +70,10 @@ def test_get_device_detects_battery_sensor(mock_openzwave): assert device.device_class == homeassistant.const.DEVICE_CLASS_BATTERY -def test_multilevelsensor_value_changed_temp_fahrenheit(mock_openzwave): +def test_multilevelsensor_value_changed_temp_fahrenheit(hass, mock_openzwave): """Test value changed for Z-Wave multilevel sensor for temperature.""" + hass.config.units.temperature_unit = homeassistant.const.TEMP_FAHRENHEIT + node = MockNode( command_classes=[ const.COMMAND_CLASS_SENSOR_MULTILEVEL, @@ -82,6 +84,7 @@ def test_multilevelsensor_value_changed_temp_fahrenheit(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 191.0 assert device.unit_of_measurement == homeassistant.const.TEMP_FAHRENHEIT assert device.device_class == homeassistant.const.DEVICE_CLASS_TEMPERATURE @@ -90,8 +93,9 @@ def test_multilevelsensor_value_changed_temp_fahrenheit(mock_openzwave): assert device.state == 198.0 -def test_multilevelsensor_value_changed_temp_celsius(mock_openzwave): +def test_multilevelsensor_value_changed_temp_celsius(hass, mock_openzwave): """Test value changed for Z-Wave multilevel sensor for temperature.""" + hass.config.units.temperature_unit = homeassistant.const.TEMP_CELSIUS node = MockNode( command_classes=[ const.COMMAND_CLASS_SENSOR_MULTILEVEL, @@ -102,6 +106,7 @@ def test_multilevelsensor_value_changed_temp_celsius(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 38.9 assert device.unit_of_measurement == homeassistant.const.TEMP_CELSIUS assert device.device_class == homeassistant.const.DEVICE_CLASS_TEMPERATURE @@ -110,7 +115,7 @@ def test_multilevelsensor_value_changed_temp_celsius(mock_openzwave): assert device.state == 38.0 -def test_multilevelsensor_value_changed_other_units(mock_openzwave): +def test_multilevelsensor_value_changed_other_units(hass, mock_openzwave): """Test value changed for Z-Wave multilevel sensor for other units.""" node = MockNode( command_classes=[ @@ -124,6 +129,7 @@ def test_multilevelsensor_value_changed_other_units(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 190.96 assert device.unit_of_measurement == homeassistant.const.ENERGY_KILO_WATT_HOUR assert device.device_class is None @@ -132,7 +138,7 @@ def test_multilevelsensor_value_changed_other_units(mock_openzwave): assert device.state == 197.96 -def test_multilevelsensor_value_changed_integer(mock_openzwave): +def test_multilevelsensor_value_changed_integer(hass, mock_openzwave): """Test value changed for Z-Wave multilevel sensor for other units.""" node = MockNode( command_classes=[ @@ -144,6 +150,7 @@ def test_multilevelsensor_value_changed_integer(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 5 assert device.unit_of_measurement == "counts" assert device.device_class is None @@ -152,7 +159,7 @@ def test_multilevelsensor_value_changed_integer(mock_openzwave): assert device.state == 6 -def test_alarm_sensor_value_changed(mock_openzwave): +def test_alarm_sensor_value_changed(hass, mock_openzwave): """Test value changed for Z-Wave sensor.""" node = MockNode( command_classes=[const.COMMAND_CLASS_ALARM, const.COMMAND_CLASS_SENSOR_ALARM] @@ -161,6 +168,7 @@ def test_alarm_sensor_value_changed(mock_openzwave): values = MockEntityValues(primary=value) device = sensor.get_device(node=node, values=values, node_config={}) + device.hass = hass assert device.state == 12.34 assert device.unit_of_measurement == homeassistant.const.PERCENTAGE assert device.device_class is None diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 44943fed9fb..2590149c462 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -1,6 +1,4 @@ """Provide common test tools for Z-Wave JS.""" -from datetime import datetime, timezone - AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" HUMIDITY_SENSOR = "sensor.multisensor_6_humidity" POWER_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" @@ -16,7 +14,7 @@ NOTIFICATION_MOTION_BINARY_SENSOR = ( ) NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" INDICATOR_SENSOR = "sensor.z_wave_thermostat_indicator_value" -BASIC_SENSOR = "sensor.livingroomlight_basic" +BASIC_NUMBER_ENTITY = "number.livingroomlight_basic" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( "binary_sensor.august_smart_lock_pro_3rd_gen_the_current_status_of_the_door" ) @@ -35,6 +33,3 @@ 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" - -DATETIME_ZERO = datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc) -DATETIME_LAST_RESET = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 0f336e396fe..6634fdf759d 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -11,11 +11,6 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo -from homeassistant.components.sensor import ATTR_LAST_RESET -from homeassistant.core import State - -from .common import DATETIME_LAST_RESET - from tests.common import MockConfigEntry, load_fixture # Add-on fixtures @@ -171,13 +166,13 @@ def uninstall_addon_fixture(): yield uninstall_addon -@pytest.fixture(name="create_shapshot") -def create_snapshot_fixture(): - """Mock create snapshot.""" +@pytest.fixture(name="create_backup") +def create_backup_fixture(): + """Mock create backup.""" with patch( - "homeassistant.components.zwave_js.addon.async_create_snapshot" - ) as create_shapshot: - yield create_shapshot + "homeassistant.components.zwave_js.addon.async_create_backup" + ) as create_backup: + yield create_backup @pytest.fixture(name="controller_state", scope="session") @@ -452,6 +447,14 @@ def aeotec_zw164_siren_state_fixture(): return json.loads(load_fixture("zwave_js/aeotec_zw164_siren_state.json")) +@pytest.fixture(name="lock_popp_electric_strike_lock_control_state", scope="session") +def lock_popp_electric_strike_lock_control_state_fixture(): + """Load the popp electric strike lock control node state fixture data.""" + return json.loads( + load_fixture("zwave_js/lock_popp_electric_strike_lock_control_state.json") + ) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -766,6 +769,16 @@ def lock_id_lock_as_id150(client, lock_id_lock_as_id150_state): return node +@pytest.fixture(name="lock_id_lock_as_id150_not_ready") +def node_not_ready(client, lock_id_lock_as_id150_state): + """Mock an id lock id-150 lock node that's not ready.""" + state = copy.deepcopy(lock_id_lock_as_id150_state) + state["ready"] = False + node = Node(client, state) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="climate_radio_thermostat_ct101_multiple_temp_units") def climate_radio_thermostat_ct101_multiple_temp_units_fixture( client, climate_radio_thermostat_ct101_multiple_temp_units_state @@ -830,26 +843,23 @@ def ge_in_wall_dimmer_switch_fixture(client, ge_in_wall_dimmer_switch_state): @pytest.fixture(name="aeotec_zw164_siren") def aeotec_zw164_siren_fixture(client, aeotec_zw164_siren_state): - """Mock a wallmote central scene node.""" + """Mock a aeotec zw164 siren node.""" node = Node(client, copy.deepcopy(aeotec_zw164_siren_state)) client.driver.controller.nodes[node.node_id] = node return node +@pytest.fixture(name="lock_popp_electric_strike_lock_control") +def lock_popp_electric_strike_lock_control_fixture( + client, lock_popp_electric_strike_lock_control_state +): + """Mock a popp electric strike lock control node.""" + node = Node(client, copy.deepcopy(lock_popp_electric_strike_lock_control_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="firmware_file") def firmware_file_fixture(): """Return mock firmware file stream.""" return io.BytesIO(bytes(10)) - - -@pytest.fixture(name="restore_last_reset") -def restore_last_reset_fixture(): - """Return mock restore last reset.""" - state = State( - "sensor.test", "test", {ATTR_LAST_RESET: DATETIME_LAST_RESET.isoformat()} - ) - with patch( - "homeassistant.components.zwave_js.sensor.ZWaveMeterSensor.async_get_last_state", - return_value=state, - ): - yield state diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 75fca7f11ff..ee05724a9cb 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3,7 +3,7 @@ import json from unittest.mock import patch import pytest -from zwave_js_server.const import LogLevel +from zwave_js_server.const import InclusionStrategy, LogLevel from zwave_js_server.event import Event from zwave_js_server.exceptions import ( FailedCommand, @@ -29,6 +29,7 @@ from homeassistant.components.zwave_js.api import ( OPTED_IN, PROPERTY, PROPERTY_KEY, + SECURE, TYPE, VALUE, ) @@ -318,6 +319,31 @@ 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": {"inclusionStrategy": InclusionStrategy.SECURITY_S0}, + } + + client.async_send_command.reset_mock() + + async def test_add_node( hass, nortek_thermostat_added_event, integration, client, hass_ws_client ): @@ -334,6 +360,12 @@ async def test_add_node( 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": {"inclusionStrategy": InclusionStrategy.INSECURE}, + } + event = Event( type="inclusion started", data={ @@ -599,6 +631,52 @@ 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": {"inclusionStrategy": InclusionStrategy.SECURITY_S0}, + } + + client.async_send_command.reset_mock() + + async def test_replace_failed_node( hass, nortek_thermostat, @@ -638,6 +716,15 @@ async def test_replace_failed_node( 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": {"inclusionStrategy": InclusionStrategy.INSECURE}, + } + + client.async_send_command.reset_mock() + event = Event( type="inclusion started", data={ diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 7d02c215d45..5e994a2ac7a 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -20,6 +20,34 @@ ADDON_DISCOVERY_INFO = { } +USB_DISCOVERY_INFO = { + "device": "/dev/zwave", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zwave radio", + "manufacturer": "test", +} + +NORTEK_ZIGBEE_DISCOVERY_INFO = { + "device": "/dev/zigbee", + "pid": "8A2A", + "vid": "10C4", + "serial_number": "1234", + "description": "nortek zigbee radio", + "manufacturer": "nortek", +} + +CP2652_ZIGBEE_DISCOVERY_INFO = { + "device": "/dev/zigbee", + "pid": "EA60", + "vid": "10C4", + "serial_number": "", + "description": "cp2652", + "manufacturer": "generic", +} + + @pytest.fixture(name="persistent_notification", autouse=True) async def setup_persistent_notification(hass): """Set up persistent notification integration.""" @@ -383,6 +411,162 @@ async def test_abort_discovery_with_existing_entry( assert entry.data["url"] == "ws://host1:3001" +async def test_abort_hassio_discovery_with_existing_flow( + hass, supervisor, addon_options +): + """Test hassio discovery flow is aborted when another discovery has happened.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "form" + assert result["step_id"] == "usb_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_in_progress" + + +async def test_usb_discovery( + hass, + supervisor, + install_addon, + addon_options, + get_addon_discovery_info, + set_addon_options, + start_addon, +): + """Test usb discovery success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "form" + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == "progress" + assert result["step_id"] == "install_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert install_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert set_addon_options.call_args == call( + hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + ) + + 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"]["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 len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_usb_discovery_addon_not_running( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test usb discovery when add-on is installed but not running.""" + addon_options["device"] = "/dev/incorrect_device" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "form" + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + # Make sure the discovered usb device is preferred. + data_schema = result["data_schema"] + assert data_schema({}) == { + "usb_path": USB_DISCOVERY_INFO["device"], + "network_key": "", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"usb_path": USB_DISCOVERY_INFO["device"], "network_key": "abc123"}, + ) + + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": {"device": USB_DISCOVERY_INFO["device"], "network_key": "abc123"}}, + ) + + 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"]["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 len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_discovery_addon_not_running( hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon ): @@ -512,6 +696,84 @@ async def test_discovery_addon_not_installed( assert len(mock_setup_entry.mock_calls) == 1 +async def test_abort_usb_discovery_with_existing_flow(hass, supervisor, addon_options): + """Test usb discovery flow is aborted when another discovery has happened.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] == "form" + assert result["step_id"] == "hassio_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result2["type"] == "abort" + assert result2["reason"] == "already_in_progress" + + +async def test_abort_usb_discovery_already_configured(hass, supervisor, addon_options): + """Test usb discovery flow is aborted when there is an existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, data={"url": "ws://localhost:3000"}, title=TITLE, unique_id=1234 + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_usb_discovery_requires_supervisor(hass): + """Test usb discovery flow is aborted when there is no supervisor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "discovery_requires_supervisor" + + +async def test_usb_discovery_already_running(hass, supervisor, addon_running): + """Test usb discovery flow is aborted when the addon is running.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "discovery_info", + [ + NORTEK_ZIGBEE_DISCOVERY_INFO, + CP2652_ZIGBEE_DISCOVERY_INFO, + ], +) +async def test_abort_usb_discovery_aborts_specific_devices( + hass, supervisor, addon_options, discovery_info +): + """Test usb discovery flow is aborted on specific devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=discovery_info, + ) + assert result["type"] == "abort" + assert result["reason"] == "not_zwave_device" + + async def test_not_addon(hass, supervisor): """Test opting out of add-on on Supervisor.""" await setup.async_setup_component(hass, "persistent_notification", {}) diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 70ce2337abf..1afe7a114da 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -23,7 +23,7 @@ from homeassistant.const import ( WINDOW_COVER_ENTITY = "cover.zws_12" GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" -SHUTTER_COVER_ENTITY = "cover.flush_shutter_dc" +SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index eef672c4c5b..dfdbb16c8e8 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -10,6 +10,9 @@ from zwave_js_server.const import CommandClass from zwave_js_server.event import Event from homeassistant.components import automation +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.zwave_js import DOMAIN, device_condition from homeassistant.components.zwave_js.helpers import get_zwave_value_from_config from homeassistant.exceptions import HomeAssistantError @@ -430,7 +433,17 @@ async def test_get_condition_capabilities_value( ) assert capabilities and "extra_fields" in capabilities - cc_options = [(cc.value, cc.name) for cc in CommandClass] + cc_options = [ + (133, "Association"), + (128, "Battery"), + (98, "Door Lock"), + (122, "Firmware Update Meta Data"), + (114, "Manufacturer Specific"), + (113, "Notification"), + (152, "Security"), + (99, "User Code"), + (134, "Version"), + ] assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer @@ -509,6 +522,7 @@ async def test_get_condition_capabilities_config_parameter( { "name": "value", "required": True, + "type": "integer", "valueMin": 0, "valueMax": 124, } @@ -555,6 +569,30 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): == {} ) + INVALID_CONFIG = { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": 9999, + "property_key": 9999, + "endpoint": 9999, + "value": 9999, + } + + # Test that invalid config raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_condition.async_validate_condition_config(hass, INVALID_CONFIG) + + # Unload entry so we can verify that validation will pass on an invalid config + # since we return early + await hass.config_entries.async_unload(integration.entry_id) + assert ( + await device_condition.async_validate_condition_config(hass, INVALID_CONFIG) + == INVALID_CONFIG + ) + async def test_get_value_from_config_failure( hass, client, hank_binary_switch, integration diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 86e053a5882..22496d3deed 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -8,11 +8,10 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components import automation -from homeassistant.components.zwave_js import DOMAIN, device_trigger -from homeassistant.components.zwave_js.device_trigger import ( - async_attach_trigger, - async_get_trigger_capabilities, +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, ) +from homeassistant.components.zwave_js import DOMAIN, device_trigger from homeassistant.components.zwave_js.helpers import ( async_get_node_status_sensor_entity_id, ) @@ -951,15 +950,342 @@ async def test_get_trigger_capabilities_scene_activation_value_notification( ] +async def test_get_value_updated_value_triggers( + hass, client, lock_schlage_be469, integration +): + """Test we get the zwave_js.value_updated.value trigger from a zwave_js device.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "zwave_js.value_updated.value", + "device_id": device.id, + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_value_updated_value_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for zwave_js.value_updated.value trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "property_key": None, + "endpoint": None, + "from": "open", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "zwave_js.value_updated.value - " + "{{ trigger.platform}} - " + "{{ trigger.previous_value }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake value update that shouldn't trigger + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "insideHandlesCanOpenDoor", + "newValue": [True, False, False, False], + "prevValue": [False, False, False, False], + "propertyName": "insideHandlesCanOpenDoor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Publish fake value update that should trigger + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "closed", + "prevValue": "open", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "zwave_js.value_updated.value - zwave_js.value_updated - open" + ) + + +async def test_get_trigger_capabilities_value_updated_value( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a zwave_js.value_updated.value trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "property_key": None, + "endpoint": None, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "command_class", + "required": True, + "type": "select", + "options": [ + (133, "Association"), + (128, "Battery"), + (98, "Door Lock"), + (122, "Firmware Update Meta Data"), + (114, "Manufacturer Specific"), + (113, "Notification"), + (152, "Security"), + (99, "User Code"), + (134, "Version"), + ], + }, + {"name": "property", "required": True, "type": "string"}, + {"name": "property_key", "optional": True, "type": "string"}, + {"name": "endpoint", "optional": True, "type": "string"}, + {"name": "from", "optional": True, "type": "string"}, + {"name": "to", "optional": True, "type": "string"}, + ] + + +async def test_get_value_updated_config_parameter_triggers( + hass, client, lock_schlage_be469, integration +): + """Test we get the zwave_js.value_updated.config_parameter trigger from a zwave_js device.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "zwave_js.value_updated.config_parameter", + "device_id": device.id, + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_value_updated_config_parameter_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for zwave_js.value_updated.config_parameter trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + "from": 255, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "zwave_js.value_updated.config_parameter - " + "{{ trigger.platform}} - " + "{{ trigger.previous_value_raw }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake value update + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 3, + "newValue": 0, + "prevValue": 255, + "propertyName": "Beeper", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "zwave_js.value_updated.config_parameter - zwave_js.value_updated - 255" + ) + + +async def test_get_trigger_capabilities_value_updated_config_parameter_range( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a range zwave_js.value_updated.config_parameter trigger.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 6, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-6 (User Slot Status)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "valueMin": 0, + "valueMax": 255, + "type": "integer", + }, + { + "name": "to", + "optional": True, + "valueMin": 0, + "valueMax": 255, + "type": "integer", + }, + ] + + +async def test_get_trigger_capabilities_value_updated_config_parameter_enumerated( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from an enumerated zwave_js.value_updated.config_parameter trigger.""" + node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.config_parameter", + "property": 3, + "property_key": None, + "endpoint": 0, + "command_class": CommandClass.CONFIGURATION.value, + "subtype": f"{node.node_id}-112-0-3 (Beeper)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "options": [(0, "Disable Beeper"), (255, "Enable Beeper")], + "type": "select", + }, + { + "name": "to", + "optional": True, + "options": [(0, "Disable Beeper"), (255, "Enable Beeper")], + "type": "select", + }, + ] + + async def test_failure_scenarios(hass, client, hank_binary_switch, integration): """Test failure scenarios.""" with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "failed.test", "device_id": "invalid_device_id"}, None, {} ) with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "event.failed_type", "device_id": "invalid_device_id"}, None, @@ -970,18 +1296,26 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "failed.test", "device_id": device.id}, None, {} ) with pytest.raises(HomeAssistantError): - await async_attach_trigger( + await device_trigger.async_attach_trigger( hass, {"type": "event.failed_type", "device_id": device.id}, None, {}, ) + with pytest.raises(HomeAssistantError): + await device_trigger.async_attach_trigger( + hass, + {"type": "state.failed_type", "device_id": device.id}, + None, + {}, + ) + with patch( "homeassistant.components.zwave_js.device_trigger.async_get_node_from_device_id", return_value=None, @@ -990,7 +1324,7 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): return_value=None, ): assert ( - await async_get_trigger_capabilities( + await device_trigger.async_get_trigger_capabilities( hass, {"type": "failed.test", "device_id": "invalid_device_id"} ) == {} @@ -998,3 +1332,26 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): with pytest.raises(HomeAssistantError): async_get_node_status_sensor_entity_id(hass, "invalid_device_id") + + INVALID_CONFIG = { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "zwave_js.value_updated.value", + "command_class": CommandClass.DOOR_LOCK.value, + "property": 9999, + "property_key": 9999, + "endpoint": 9999, + } + + # Test that invalid config raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config(hass, INVALID_CONFIG) + + # Unload entry so we can verify that validation will pass on an invalid config + # since we return early + await hass.config_entries.async_unload(integration.entry_id) + assert ( + await device_trigger.async_validate_trigger_config(hass, INVALID_CONFIG) + == INVALID_CONFIG + ) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 8914019cd43..9758d3b0f44 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -57,6 +57,17 @@ async def test_vision_security_zl7432( assert state.attributes["assumed_state"] +async def test_lock_popp_electric_strike_lock_control( + hass, client, lock_popp_electric_strike_lock_control, integration +): + """Test that the Popp Electric Strike Lock Control gets discovered correctly.""" + assert hass.states.get("lock.node_62") is not None + assert ( + hass.states.get("binary_sensor.node_62_the_current_status_of_the_door") + is not None + ) + + async def test_firmware_version_range_exception(hass): """Test FirmwareVersionRange exception.""" with pytest.raises(ValueError): diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 0b9009cd1d7..447b052b8c0 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -365,8 +365,8 @@ async def test_addon_options_changed( @pytest.mark.parametrize( - "addon_version, update_available, update_calls, snapshot_calls, " - "update_addon_side_effect, create_shapshot_side_effect", + "addon_version, update_available, update_calls, backup_calls, " + "update_addon_side_effect, create_backup_side_effect", [ ("1.0", True, 1, 1, None, None), ("1.0", False, 0, 0, None, None), @@ -380,15 +380,15 @@ async def test_update_addon( addon_info, addon_installed, addon_running, - create_shapshot, + create_backup, update_addon, addon_options, addon_version, update_available, update_calls, - snapshot_calls, + backup_calls, update_addon_side_effect, - create_shapshot_side_effect, + create_backup_side_effect, ): """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -397,7 +397,7 @@ async def test_update_addon( addon_options["network_key"] = network_key addon_info.return_value["version"] = addon_version addon_info.return_value["update_available"] = update_available - create_shapshot.side_effect = create_shapshot_side_effect + create_backup.side_effect = create_backup_side_effect update_addon.side_effect = update_addon_side_effect client.connect.side_effect = InvalidServerVersion("Invalid version") entry = MockConfigEntry( @@ -416,7 +416,7 @@ async def test_update_addon( await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY - assert create_shapshot.call_count == snapshot_calls + assert create_backup.call_count == backup_calls assert update_addon.call_count == update_calls @@ -469,7 +469,7 @@ async def test_stop_addon( async def test_remove_entry( - hass, addon_installed, stop_addon, create_shapshot, uninstall_addon, caplog + hass, addon_installed, stop_addon, create_backup, uninstall_addon, caplog ): """Test remove the config entry.""" # test successful remove without created add-on @@ -500,8 +500,8 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_shapshot.call_count == 1 - assert create_shapshot.call_args == call( + assert create_backup.call_count == 1 + assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, @@ -511,7 +511,7 @@ async def test_remove_entry( assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() - create_shapshot.reset_mock() + create_backup.reset_mock() uninstall_addon.reset_mock() # test add-on stop failure @@ -523,27 +523,27 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_shapshot.call_count == 0 + assert create_backup.call_count == 0 assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to stop the Z-Wave JS add-on" in caplog.text stop_addon.side_effect = None stop_addon.reset_mock() - create_shapshot.reset_mock() + create_backup.reset_mock() uninstall_addon.reset_mock() - # test create snapshot failure + # test create backup failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - create_shapshot.side_effect = HassioAPIError() + create_backup.side_effect = HassioAPIError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_shapshot.call_count == 1 - assert create_shapshot.call_args == call( + assert create_backup.call_count == 1 + assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, @@ -551,10 +551,10 @@ async def test_remove_entry( assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - assert "Failed to create a snapshot of the Z-Wave JS add-on" in caplog.text - create_shapshot.side_effect = None + assert "Failed to create a backup of the Z-Wave JS add-on" in caplog.text + create_backup.side_effect = None stop_addon.reset_mock() - create_shapshot.reset_mock() + create_backup.reset_mock() uninstall_addon.reset_mock() # test add-on uninstall failure @@ -566,8 +566,8 @@ async def test_remove_entry( assert stop_addon.call_count == 1 assert stop_addon.call_args == call(hass, "core_zwave_js") - assert create_shapshot.call_count == 1 - assert create_shapshot.call_args == call( + assert create_backup.call_count == 1 + assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, partial=True, diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index fa3c73a9a42..373ca2525ac 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -129,7 +129,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_COLOR_TEMP] == 370 - assert ATTR_RGB_COLOR not in state.attributes + assert ATTR_RGB_COLOR in state.attributes # Test turning on with same brightness await hass.services.async_call( @@ -223,58 +223,23 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 - warm_args = client.async_send_command.call_args_list[0][0][0] # red 255 - assert warm_args["command"] == "node.set_value" - assert warm_args["nodeId"] == 39 - assert warm_args["valueId"]["commandClassName"] == "Color Switch" - assert warm_args["valueId"]["commandClass"] == 51 - assert warm_args["valueId"]["endpoint"] == 0 - assert warm_args["valueId"]["metadata"]["label"] == "Target value (Red)" - assert warm_args["valueId"]["property"] == "targetColor" - assert warm_args["valueId"]["propertyName"] == "targetColor" - assert warm_args["value"] == 255 - - cold_args = client.async_send_command.call_args_list[1][0][0] # green 76 - assert cold_args["command"] == "node.set_value" - assert cold_args["nodeId"] == 39 - assert cold_args["valueId"]["commandClassName"] == "Color Switch" - assert cold_args["valueId"]["commandClass"] == 51 - assert cold_args["valueId"]["endpoint"] == 0 - assert cold_args["valueId"]["metadata"]["label"] == "Target value (Green)" - assert cold_args["valueId"]["property"] == "targetColor" - assert cold_args["valueId"]["propertyName"] == "targetColor" - assert cold_args["value"] == 76 - red_args = client.async_send_command.call_args_list[2][0][0] # blue 255 - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Blue)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 255 - green_args = client.async_send_command.call_args_list[3][0][0] # warm white 0 - assert green_args["command"] == "node.set_value" - assert green_args["nodeId"] == 39 - assert green_args["valueId"]["commandClassName"] == "Color Switch" - assert green_args["valueId"]["commandClass"] == 51 - assert green_args["valueId"]["endpoint"] == 0 - assert green_args["valueId"]["metadata"]["label"] == "Target value (Warm White)" - assert green_args["valueId"]["property"] == "targetColor" - assert green_args["valueId"]["propertyName"] == "targetColor" - assert green_args["value"] == 0 - blue_args = client.async_send_command.call_args_list[4][0][0] # cold white 0 - assert blue_args["command"] == "node.set_value" - assert blue_args["nodeId"] == 39 - assert blue_args["valueId"]["commandClassName"] == "Color Switch" - assert blue_args["valueId"]["commandClass"] == 51 - assert blue_args["valueId"]["endpoint"] == 0 - assert blue_args["valueId"]["metadata"]["label"] == "Target value (Cold White)" - assert blue_args["valueId"]["property"] == "targetColor" - assert blue_args["valueId"]["propertyName"] == "targetColor" - assert blue_args["value"] == 0 + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 39 + assert args["valueId"]["commandClassName"] == "Color Switch" + assert args["valueId"]["commandClass"] == 51 + assert args["valueId"]["endpoint"] == 0 + assert args["valueId"]["metadata"]["label"] == "Target Color" + assert args["valueId"]["property"] == "targetColor" + assert args["valueId"]["propertyName"] == "targetColor" + assert args["value"] == { + "blue": 255, + "coldWhite": 0, + "green": 76, + "red": 255, + "warmWhite": 0, + } # Test rgb color update from value updated event red_event = Event( @@ -328,7 +293,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 + assert len(client.async_send_command.call_args_list) == 2 client.async_send_command.reset_mock() @@ -344,8 +309,8 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 - args = client.async_send_command.call_args_list[5][0][0] + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] assert args["options"]["transitionDuration"] == "20s" client.async_send_command.reset_mock() @@ -357,57 +322,23 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 - red_args = client.async_send_command.call_args_list[0][0][0] # red 0 - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Red)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 0 - red_args = client.async_send_command.call_args_list[1][0][0] # green 0 - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Green)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 0 - red_args = client.async_send_command.call_args_list[2][0][0] # blue 0 - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Blue)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 0 - warm_args = client.async_send_command.call_args_list[3][0][0] # warm white 0 - assert warm_args["command"] == "node.set_value" - assert warm_args["nodeId"] == 39 - assert warm_args["valueId"]["commandClassName"] == "Color Switch" - assert warm_args["valueId"]["commandClass"] == 51 - assert warm_args["valueId"]["endpoint"] == 0 - assert warm_args["valueId"]["metadata"]["label"] == "Target value (Warm White)" - assert warm_args["valueId"]["property"] == "targetColor" - assert warm_args["valueId"]["propertyName"] == "targetColor" - assert warm_args["value"] == 20 - red_args = client.async_send_command.call_args_list[4][0][0] # cold white - assert red_args["command"] == "node.set_value" - assert red_args["nodeId"] == 39 - assert red_args["valueId"]["commandClassName"] == "Color Switch" - assert red_args["valueId"]["commandClass"] == 51 - assert red_args["valueId"]["endpoint"] == 0 - assert red_args["valueId"]["metadata"]["label"] == "Target value (Cold White)" - assert red_args["valueId"]["property"] == "targetColor" - assert red_args["valueId"]["propertyName"] == "targetColor" - assert red_args["value"] == 235 + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] # red 0 + assert args["command"] == "node.set_value" + assert args["nodeId"] == 39 + assert args["valueId"]["commandClassName"] == "Color Switch" + assert args["valueId"]["commandClass"] == 51 + assert args["valueId"]["endpoint"] == 0 + assert args["valueId"]["metadata"]["label"] == "Target Color" + assert args["valueId"]["property"] == "targetColor" + assert args["valueId"]["propertyName"] == "targetColor" + assert args["value"] == { + "blue": 0, + "coldWhite": 235, + "green": 0, + "red": 0, + "warmWhite": 20, + } client.async_send_command.reset_mock() @@ -456,7 +387,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_COLOR_TEMP] == 170 - assert ATTR_RGB_COLOR not in state.attributes + assert ATTR_RGB_COLOR in state.attributes # Test turning on with same color temp await hass.services.async_call( @@ -466,7 +397,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 + assert len(client.async_send_command.call_args_list) == 2 client.async_send_command.reset_mock() @@ -482,8 +413,8 @@ async def test_light(hass, client, bulb_6_multi_color, integration): blocking=True, ) - assert len(client.async_send_command.call_args_list) == 6 - args = client.async_send_command.call_args_list[5][0][0] + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] assert args["options"]["transitionDuration"] == "35s" client.async_send_command.reset_mock() diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 3727ab9d288..9a0735d3dc6 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,5 +1,5 @@ """Test the Z-Wave JS lock platform.""" -from zwave_js_server.const import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE from zwave_js_server.event import Event from zwave_js_server.model.node import NodeStatus diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index b7d83068bea..6d9458d096c 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -1,7 +1,13 @@ """Test the Z-Wave JS number platform.""" from zwave_js_server.event import Event +from homeassistant.const import STATE_UNKNOWN +from homeassistant.helpers import entity_registry as er + +from .common import BASIC_NUMBER_ENTITY + NUMBER_ENTITY = "number.thermostat_hvac_valve_control" +VOLUME_NUMBER_ENTITY = "number.indoor_siren_6_default_volume_2" async def test_number(hass, client, aeotec_radiator_thermostat, integration): @@ -67,3 +73,105 @@ async def test_number(hass, client, aeotec_radiator_thermostat, integration): state = hass.states.get(NUMBER_ENTITY) assert state.state == "99.0" + + +async def test_volume_number(hass, client, aeotec_zw164_siren, integration): + """Test the volume number entity.""" + node = aeotec_zw164_siren + state = hass.states.get(VOLUME_NUMBER_ENTITY) + + assert state + assert state.state == "1.0" + assert state.attributes["step"] == 0.01 + assert state.attributes["max"] == 1.0 + assert state.attributes["min"] == 0 + + # Test turn on setting value + await hass.services.async_call( + "number", + "set_value", + {"entity_id": VOLUME_NUMBER_ENTITY, "value": 0.3}, + 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": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%", + }, + "value": 100, + } + assert args["value"] == 30 + + 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": 4, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultVolume", + "newValue": 30, + "prevValue": 100, + "propertyName": "defaultVolume", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(VOLUME_NUMBER_ENTITY) + assert state.state == "0.3" + + # Test null value + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 4, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultVolume", + "newValue": None, + "prevValue": 30, + "propertyName": "defaultVolume", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(VOLUME_NUMBER_ENTITY) + assert state.state == STATE_UNKNOWN + + +async def test_disabled_basic_number(hass, ge_in_wall_dimmer_switch, integration): + """Test number is created from Basic CC and is disabled.""" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(BASIC_NUMBER_ENTITY) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py new file mode 100644 index 00000000000..43f44f0bba0 --- /dev/null +++ b/tests/components/zwave_js/test_select.py @@ -0,0 +1,201 @@ +"""Test the Z-Wave JS number platform.""" +from zwave_js_server.event import Event + +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" + + +async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration): + """Test the default tone select entity.""" + node = aeotec_zw164_siren + state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) + + assert state + assert state.state == "17ALAR~1 (35 sec)" + attr = state.attributes + assert attr["options"] == [ + "01DING~1 (5 sec)", + "02DING~1 (9 sec)", + "03TRAD~1 (11 sec)", + "04ELEC~1 (2 sec)", + "05WEST~1 (13 sec)", + "06CHIM~1 (7 sec)", + "07CUCK~1 (31 sec)", + "08TRAD~1 (6 sec)", + "09SMOK~1 (11 sec)", + "10SMOK~1 (6 sec)", + "11FIRE~1 (35 sec)", + "12COSE~1 (5 sec)", + "13KLAX~1 (38 sec)", + "14DEEP~1 (41 sec)", + "15WARN~1 (37 sec)", + "16TORN~1 (46 sec)", + "17ALAR~1 (35 sec)", + "18DEEP~1 (62 sec)", + "19ALAR~1 (15 sec)", + "20ALAR~1 (7 sec)", + "21DIGI~1 (8 sec)", + "22ALER~1 (64 sec)", + "23SHIP~1 (4 sec)", + "25CHRI~1 (4 sec)", + "26GONG~1 (12 sec)", + "27SING~1 (1 sec)", + "28TONA~1 (5 sec)", + "29UPWA~1 (2 sec)", + "30DOOR~1 (27 sec)", + ] + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + {"entity_id": DEFAULT_TONE_SELECT_ENTITY, "option": "30DOOR~1 (27 sec)"}, + 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": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Default tone ID", + "min": 0, + "max": 254, + }, + "value": 17, + } + assert args["value"] == 30 + + client.async_send_command.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultToneId", + "newValue": 30, + "prevValue": 17, + "propertyName": "defaultToneId", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) + assert state.state == "30DOOR~1 (27 sec)" + + +async def test_protection_select(hass, client, inovelli_lzw36, integration): + """Test the default tone select entity.""" + node = inovelli_lzw36 + state = hass.states.get(PROTECTION_SELECT_ENTITY) + + assert state + assert state.state == "Unprotected" + attr = state.attributes + assert attr["options"] == [ + "Unprotected", + "ProtectedBySequence", + "NoOperationPossible", + ] + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + {"entity_id": PROTECTION_SELECT_ENTITY, "option": "ProtectedBySequence"}, + 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": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "1": "ProtectedBySequence", + "2": "NoOperationPossible", + }, + }, + "value": 0, + } + assert args["value"] == 1 + + 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": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "newValue": 1, + "prevValue": 0, + "propertyName": "local", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(PROTECTION_SELECT_ENTITY) + assert state.state == "ProtectedBySequence" + + # Test null value + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "newValue": None, + "prevValue": 1, + "propertyName": "local", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(PROTECTION_SELECT_ENTITY) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 04583559421..b595b6462b3 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,9 +1,10 @@ """Test the Z-Wave JS sensor platform.""" -from unittest.mock import patch - from zwave_js_server.event import Event -from homeassistant.components.sensor import ATTR_LAST_RESET, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) from homeassistant.components.zwave_js.const import ( ATTR_METER_TYPE, ATTR_VALUE, @@ -22,22 +23,19 @@ from homeassistant.const import ( ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, + STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.helpers import entity_registry as er from .common import ( AIR_TEMPERATURE_SENSOR, - BASIC_SENSOR, CURRENT_SENSOR, - DATETIME_LAST_RESET, - DATETIME_ZERO, ENERGY_SENSOR, HUMIDITY_SENSOR, ID_LOCK_CONFIG_PARAMETER_SENSOR, INDICATOR_SENSOR, METER_ENERGY_SENSOR, - METER_VOLTAGE_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, VOLTAGE_SENSOR, @@ -77,7 +75,7 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.state == "0.16" assert state.attributes["unit_of_measurement"] == ENERGY_KILO_WATT_HOUR assert state.attributes["device_class"] == DEVICE_CLASS_ENERGY - assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT + assert state.attributes["state_class"] == STATE_CLASS_TOTAL_INCREASING state = hass.states.get(VOLTAGE_SENSOR) @@ -131,16 +129,6 @@ async def test_disabled_indcator_sensor( assert entity_entry.disabled_by == er.DISABLED_INTEGRATION -async def test_disabled_basic_sensor(hass, ge_in_wall_dimmer_switch, integration): - """Test sensor is created from Basic CC and is disabled.""" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(BASIC_SENSOR) - - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by == er.DISABLED_INTEGRATION - - async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration): """Test config parameter sensor is created.""" ent_reg = er.async_get(hass) @@ -149,7 +137,7 @@ async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration) assert entity_entry.disabled -async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): +async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integration): """Test node status sensor is created and gets updated on node state changes.""" NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150 @@ -192,6 +180,44 @@ async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + # Disconnect the client and make sure the entity is still available + await client.disconnect() + assert hass.states.get(NODE_STATUS_ENTITY).state != STATE_UNAVAILABLE + + +async def test_node_status_sensor_not_ready( + hass, + client, + lock_id_lock_as_id150_not_ready, + lock_id_lock_as_id150_state, + integration, +): + """Test node status sensor is created and available if node is not ready.""" + NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node = lock_id_lock_as_id150_not_ready + assert not node.ready + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + assert not updated_entry.disabled + assert hass.states.get(NODE_STATUS_ENTITY) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + + # Mark node as ready + event = Event("ready", {"nodeState": lock_id_lock_as_id150_state}) + node.receive_event(event) + assert node.ready + assert hass.states.get(NODE_STATUS_ENTITY) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + async def test_reset_meter( hass, @@ -203,31 +229,14 @@ async def test_reset_meter( client.async_send_command.return_value = {} client.async_send_command_no_wait.return_value = {} - # Validate that non accumulating meter does not have a last reset attribute - - assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes - - # Validate that the sensor last reset is starting from nothing - assert ( - hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] - == DATETIME_ZERO.isoformat() - ) - - # Test successful meter reset call, patching utcnow so we can make sure the last - # reset gets updated - with patch("homeassistant.util.dt.utcnow", return_value=DATETIME_LAST_RESET): - await hass.services.async_call( - DOMAIN, - SERVICE_RESET_METER, - { - ATTR_ENTITY_ID: METER_ENERGY_SENSOR, - }, - blocking=True, - ) - - assert ( - hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] - == DATETIME_LAST_RESET.isoformat() + # Test successful meter reset call + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + { + ATTR_ENTITY_ID: METER_ENERGY_SENSOR, + }, + blocking=True, ) assert len(client.async_send_command_no_wait.call_args_list) == 1 @@ -237,10 +246,6 @@ async def test_reset_meter( assert args["endpoint"] == 0 assert args["args"] == [] - # Validate that non accumulating meter does not have a last reset attribute - - assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes - client.async_send_command_no_wait.reset_mock() # Test successful meter reset call with options @@ -262,26 +267,4 @@ async def test_reset_meter( assert args["endpoint"] == 0 assert args["args"] == [{"type": 1, "targetValue": 2}] - # Validate that non accumulating meter does not have a last reset attribute - - assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes - client.async_send_command_no_wait.reset_mock() - - -async def test_restore_last_reset( - hass, - client, - aeon_smart_switch_6, - restore_last_reset, - integration, -): - """Test restoring last_reset on setup.""" - assert ( - hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] - == DATETIME_LAST_RESET.isoformat() - ) - - # Validate that non accumulating meter does not have a last reset attribute - - assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 4cc5b599f19..0831d08b216 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -5,6 +5,7 @@ import pytest import voluptuous as vol from zwave_js_server.exceptions import SetValueFailed +from homeassistant.components.group import Group from homeassistant.components.zwave_js.const import ( ATTR_BROADCAST, ATTR_COMMAND_CLASS, @@ -25,12 +26,15 @@ from homeassistant.components.zwave_js.const import ( SERVICE_SET_CONFIG_PARAMETER, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.helpers.area_registry import async_get as async_get_area_reg from homeassistant.helpers.device_registry import ( async_entries_for_config_entry, async_get as async_get_dev_reg, ) from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg +from homeassistant.setup import async_setup_component from .common import ( AEON_SMART_SWITCH_LIGHT_ENTITY, @@ -224,6 +228,52 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): client.async_send_command_no_wait.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + ent_reg.async_update_entity(entity_entry.entity_id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_AREA_ID: area.id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 41, + "propertyName": "Temperature Threshold (Unit)", + "propertyKey": 15, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 3, + "min": 1, + "max": 2, + "default": 1, + "format": 0, + "allowManualEntry": False, + "states": {"1": "Celsius", "2": "Fahrenheit"}, + "label": "Temperature Threshold (Unit)", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command_no_wait.reset_mock() + # Test setting parameter by property and bitmask await hass.services.async_call( DOMAIN, @@ -268,6 +318,52 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): client.async_send_command_no_wait.reset_mock() + # Test groups get expanded + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: "group.test", + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: "0x01", + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command_no_wait.reset_mock() + # Test that we can't include a bitmask value if parameter is a string with pytest.raises(vol.Invalid): await hass.services.async_call( @@ -430,6 +526,33 @@ async def test_bulk_set_config_parameters(hass, client, multisensor_6, integrati client.async_send_command_no_wait.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, + { + ATTR_AREA_ID: area.id, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_VALUE: 241, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClass": 112, + "property": 102, + } + assert args["value"] == 241 + + client.async_send_command_no_wait.reset_mock() + await hass.services.async_call( DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -550,11 +673,43 @@ async def test_bulk_set_config_parameters(hass, client, multisensor_6, integrati client.async_send_command.reset_mock() + # Test groups get expanded + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await hass.services.async_call( + DOMAIN, + SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, + { + ATTR_ENTITY_ID: "group.test", + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_VALUE: { + 1: 1, + 16: 1, + 32: 1, + 64: 1, + 128: 1, + }, + }, + blocking=True, + ) -async def test_poll_value( + 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"] == 52 + assert args["valueId"] == { + "commandClass": 112, + "property": 102, + } + assert args["value"] == 241 + + client.async_send_command.reset_mock() + + +async def test_refresh_value( hass, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration ): - """Test the poll_value service.""" + """Test the refresh_value service.""" # Test polling the primary value client.async_send_command.return_value = {"result": 2} await hass.services.async_call( @@ -620,6 +775,25 @@ async def test_poll_value( ) assert len(client.async_send_command.call_args_list) == 8 + client.async_send_command.reset_mock() + + # Test groups get expanded + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group(hass, "test", [CLIMATE_RADIO_THERMOSTAT_ENTITY]) + client.async_send_command.return_value = {"result": 2} + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + { + ATTR_ENTITY_ID: "group.test", + ATTR_REFRESH_ALL_VALUES: "true", + }, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 8 + + client.async_send_command.reset_mock() + # Test polling against an invalid entity raises MultipleInvalid with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( @@ -709,6 +883,87 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): client.async_send_command.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_AREA_ID: area.id, + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: "0x2", + ATTR_WAIT_FOR_RESULT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 5 + assert args["valueId"] == { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": {"0": "Unprotected", "2": "NoOperationPossible"}, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + + # Test groups get expanded + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group(hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY]) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "group.test", + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: "0x2", + ATTR_WAIT_FOR_RESULT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 5 + assert args["valueId"] == { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": {"0": "Unprotected", "2": "NoOperationPossible"}, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + # Test that when a command fails we raise an exception client.async_send_command.return_value = {"success": False} @@ -749,6 +1004,8 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): } assert args["value"] == 2 + client.async_send_command.reset_mock() + # Test missing device and entities keys with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( @@ -878,6 +1135,83 @@ async def test_multicast_set_value( client.async_send_command.reset_mock() + # Test using area ID + dev_reg = async_get_dev_reg(hass) + device_eurotronic = dev_reg.async_get_device( + {get_device_id(client, climate_eurotronic_spirit_z)} + ) + assert device_eurotronic + device_danfoss = dev_reg.async_get_device( + {get_device_id(client, climate_danfoss_lc_13)} + ) + assert device_danfoss + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device_eurotronic.id, area_id=area.id) + dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_AREA_ID: area.id, + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, + ATTR_VALUE: "0x2", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "multicast_group.set_value" + assert args["nodeIDs"] == [ + climate_eurotronic_spirit_z.node_id, + climate_danfoss_lc_13.node_id, + ] + assert args["valueId"] == { + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + + # Test groups get expanded for multicast call + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group( + hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY] + ) + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: "group.test", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, + ATTR_VALUE: "0x2", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "multicast_group.set_value" + assert args["nodeIDs"] == [ + climate_eurotronic_spirit_z.node_id, + climate_danfoss_lc_13.node_id, + ] + assert args["valueId"] == { + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + # Test successful broadcast call await hass.services.async_call( DOMAIN, @@ -1055,6 +1389,16 @@ async def test_ping( integration, ): """Test ping service.""" + dev_reg = async_get_dev_reg(hass) + device_radio_thermostat = dev_reg.async_get_device( + {get_device_id(client, climate_radio_thermostat_ct100_plus_different_endpoints)} + ) + assert device_radio_thermostat + device_danfoss = dev_reg.async_get_device( + {get_device_id(client, climate_danfoss_lc_13)} + ) + assert device_danfoss + client.async_send_command.return_value = {"responded": True} # Test successful ping call @@ -1071,7 +1415,91 @@ async def test_ping( ) assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args[0][0] + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.ping" + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + + # Test successful ping call with devices + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + { + ATTR_DEVICE_ID: [ + device_radio_thermostat.id, + device_danfoss.id, + ], + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.ping" + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + + # Test successful ping call with area + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device_radio_thermostat.id, area_id=area.id) + dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + {ATTR_AREA_ID: area.id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.ping" + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + + # Test groups get expanded for multicast call + assert await async_setup_component(hass, "group", {}) + await Group.async_create_group( + hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY] + ) + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + { + ATTR_ENTITY_ID: "group.test", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] assert args["command"] == "node.ping" assert args["nodeId"] == climate_danfoss_lc_13.node_id diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 937b2c0fa67..ebe437eb981 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -2,6 +2,7 @@ from zwave_js_server.event import Event from homeassistant.components.siren import ATTR_TONE, ATTR_VOLUME_LEVEL +from homeassistant.components.siren.const import ATTR_AVAILABLE_TONES from homeassistant.const import STATE_OFF, STATE_ON SIREN_ENTITY = "siren.indoor_siren_6_2" @@ -65,6 +66,39 @@ async def test_siren(hass, client, aeotec_zw164_siren, integration): assert state assert state.state == STATE_OFF + assert state.attributes.get(ATTR_AVAILABLE_TONES) == { + 0: "off", + 1: "01DING~1 (5 sec)", + 2: "02DING~1 (9 sec)", + 3: "03TRAD~1 (11 sec)", + 4: "04ELEC~1 (2 sec)", + 5: "05WEST~1 (13 sec)", + 6: "06CHIM~1 (7 sec)", + 7: "07CUCK~1 (31 sec)", + 8: "08TRAD~1 (6 sec)", + 9: "09SMOK~1 (11 sec)", + 10: "10SMOK~1 (6 sec)", + 11: "11FIRE~1 (35 sec)", + 12: "12COSE~1 (5 sec)", + 13: "13KLAX~1 (38 sec)", + 14: "14DEEP~1 (41 sec)", + 15: "15WARN~1 (37 sec)", + 16: "16TORN~1 (46 sec)", + 17: "17ALAR~1 (35 sec)", + 18: "18DEEP~1 (62 sec)", + 19: "19ALAR~1 (15 sec)", + 20: "20ALAR~1 (7 sec)", + 21: "21DIGI~1 (8 sec)", + 22: "22ALER~1 (64 sec)", + 23: "23SHIP~1 (4 sec)", + 25: "25CHRI~1 (4 sec)", + 26: "26GONG~1 (12 sec)", + 27: "27SING~1 (1 sec)", + 28: "28TONA~1 (5 sec)", + 29: "29UPWA~1 (2 sec)", + 30: "30DOOR~1 (27 sec)", + 255: "default", + } # Test turn on with default await hass.services.async_call( @@ -105,6 +139,28 @@ async def test_siren(hass, client, aeotec_zw164_siren, integration): client.async_send_command.reset_mock() + # Test turn on with specific tone ID and volume level + await hass.services.async_call( + "siren", + "turn_on", + { + "entity_id": SIREN_ENTITY, + ATTR_TONE: 1, + ATTR_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == TONE_ID_VALUE_ID + assert args["value"] == 1 + assert args["options"] == {"volume": 50} + + client.async_send_command.reset_mock() + # Test turn off await hass.services.async_call( "siren", diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py new file mode 100644 index 00000000000..33f6205c7b9 --- /dev/null +++ b/tests/components/zwave_js/test_trigger.py @@ -0,0 +1,276 @@ +"""The tests for Z-Wave JS automation triggers.""" +from unittest.mock import AsyncMock, patch + +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node + +from homeassistant.components import automation +from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.trigger import async_validate_trigger_config +from homeassistant.const import SERVICE_RELOAD +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get as async_get_dev_reg, +) +from homeassistant.setup import async_setup_component + +from .common import SCHLAGE_BE469_LOCK_ENTITY + +from tests.common import async_capture_events + + +async def test_zwave_js_value_updated(hass, client, lock_schlage_be469, integration): + """Test for zwave_js.value_updated automation trigger.""" + trigger_type = f"{DOMAIN}.value_updated" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + no_value_filter = async_capture_events(hass, "no_value_filter") + single_from_value_filter = async_capture_events(hass, "single_from_value_filter") + multiple_from_value_filters = async_capture_events( + hass, "multiple_from_value_filters" + ) + from_and_to_value_filters = async_capture_events(hass, "from_and_to_value_filters") + different_value = async_capture_events(hass, "different_value") + + def clear_events(): + """Clear all events in the event list.""" + no_value_filter.clear() + single_from_value_filter.clear() + multiple_from_value_filters.clear() + from_and_to_value_filters.clear() + different_value.clear() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # no value filter + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "no_value_filter", + }, + }, + # single from value filter + { + "trigger": { + "platform": trigger_type, + "device_id": device.id, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": "ajar", + }, + "action": { + "event": "single_from_value_filter", + }, + }, + # multiple from value filters + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + }, + "action": { + "event": "multiple_from_value_filters", + }, + }, + # from and to value filters + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + "to": ["opened"], + }, + "action": { + "event": "from_and_to_value_filters", + }, + }, + # different value + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "boltStatus", + }, + "action": { + "event": "different_value", + }, + }, + ] + }, + ) + + # Test that no value filter is triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that a single_from_value_filter is triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "ajar", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 1 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that multiple_from_value_filters are triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "closed", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 1 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 0 + + clear_events() + + # Test that from_and_to_value_filters is triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "opened", + "prevValue": "closed", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 1 + assert len(from_and_to_value_filters) == 1 + assert len(different_value) == 0 + + clear_events() + + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "boltStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "boltStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 0 + assert len(single_from_value_filter) == 0 + assert len(multiple_from_value_filters) == 0 + assert len(from_and_to_value_filters) == 0 + assert len(different_value) == 1 + + clear_events() + + with patch("homeassistant.config.load_yaml", return_value={}): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + +async def test_async_validate_trigger_config(hass): + """Test async_validate_trigger_config.""" + mock_platform = AsyncMock() + with patch( + "homeassistant.components.zwave_js.trigger._get_trigger_platform", + return_value=mock_platform, + ): + mock_platform.async_validate_trigger_config.return_value = {} + await async_validate_trigger_config(hass, {}) + mock_platform.async_validate_trigger_config.assert_awaited() diff --git a/tests/fixtures/homekit_controller/arlo_baby.json b/tests/fixtures/homekit_controller/arlo_baby.json new file mode 100644 index 00000000000..6a124a5f56f --- /dev/null +++ b/tests/fixtures/homekit_controller/arlo_baby.json @@ -0,0 +1,484 @@ +[ + { + "aid": 1, + "services": [ + { + "type": "0000003E-0000-1000-8000-0026BB765291", + "iid": 1, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "value": "ArloBabyA0", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "value": "Netgear, Inc", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "value": "00A0000000000", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "value": "ABC1000", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 7, + "value": "1.10.931", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": [ + "pw" + ], + "format": "bool" + } + ] + }, + { + "type": "000000A2-0000-1000-8000-0026BB765291", + "iid": 20, + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "iid": 21, + "value": "1.1.0", + "perms": [ + "pr" + ], + "format": "string" + } + ] + }, + { + "type": "00000110-0000-1000-8000-0026BB765291", + "iid": 100, + "characteristics": [ + { + "type": "00000120-0000-1000-8000-0026BB765291", + "iid": 106, + "value": "AQEB", + "perms": [ + "pr", + "ev" + ], + "format": "tlv8" + }, + { + "type": "00000114-0000-1000-8000-0026BB765291", + "iid": 101, + "value": "AY8BAQACFQEBAAEBAQEBAQIBAAMBAAQBAAUBAQMLAQKABwICOAQDAR4DCwECAAUCAsADAwEeAwsBAgAEAgIAAwMBHgMLAQIABQIC0AIDAR4DCwECgAICAmgBAwEeAwsBAuABAgIOAQMBHgMLAQKAAgIC4AEDAR4DCwEC4AECAmgBAwEeAwsBAkABAgLwAAMBHg==", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000115-0000-1000-8000-0026BB765291", + "iid": 102, + "value": "AQ4BAQMCCQEBAQIBAAMBAQIBAA==", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000116-0000-1000-8000-0026BB765291", + "iid": 103, + "value": "AgEAAgEBAgEC", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000117-0000-1000-8000-0026BB765291", + "iid": 104, + "value": "", + "perms": [ + "pr", + "pw" + ], + "format": "tlv8" + }, + { + "type": "00000118-0000-1000-8000-0026BB765291", + "iid": 108, + "value": "", + "perms": [ + "pr", + "pw" + ], + "format": "tlv8" + } + ] + }, + { + "type": "00000110-0000-1000-8000-0026BB765291", + "iid": 110, + "characteristics": [ + { + "type": "00000120-0000-1000-8000-0026BB765291", + "iid": 116, + "value": "AQEA", + "perms": [ + "pr", + "ev" + ], + "format": "tlv8" + }, + { + "type": "00000114-0000-1000-8000-0026BB765291", + "iid": 111, + "value": "AWgBAQACFQEBAAEBAQEBAQIBAAMBAAQBAAUBAQMLAQIABQIC0AIDAR4DCwECgAICAmgBAwEeAwsBAuABAgIOAQMBHgMLAQKAAgIC4AEDAR4DCwEC4AECAmgBAwEeAwsBAkABAgLwAAMBHg==", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000115-0000-1000-8000-0026BB765291", + "iid": 112, + "value": "AQ4BAQMCCQEBAQIBAAMBAQIBAA==", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000116-0000-1000-8000-0026BB765291", + "iid": 113, + "value": "AgEAAgEBAgEC", + "perms": [ + "pr" + ], + "format": "tlv8" + }, + { + "type": "00000117-0000-1000-8000-0026BB765291", + "iid": 114, + "value": "", + "perms": [ + "pr", + "pw" + ], + "format": "tlv8" + }, + { + "type": "00000118-0000-1000-8000-0026BB765291", + "iid": 118, + "value": "", + "perms": [ + "pr", + "pw" + ], + "format": "tlv8" + } + ] + }, + { + "type": "00000112-0000-1000-8000-0026BB765291", + "iid": 300, + "characteristics": [ + { + "type": "0000011A-0000-1000-8000-0026BB765291", + "iid": 302, + "value": false, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "bool" + } + ] + }, + { + "type": "00000113-0000-1000-8000-0026BB765291", + "iid": 400, + "characteristics": [ + { + "type": "0000011A-0000-1000-8000-0026BB765291", + "iid": 402, + "value": false, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "bool" + }, + { + "type": "00000119-0000-1000-8000-0026BB765291", + "iid": 403, + "value": 50, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "unit": "percentage" + } + ] + }, + { + "type": "00000085-0000-1000-8000-0026BB765291", + "iid": 500, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 501, + "value": "Motion", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 502, + "value": false, + "perms": [ + "pr", + "ev" + ], + "format": "bool" + } + ] + }, + { + "type": "00000096-0000-1000-8000-0026BB765291", + "iid": 700, + "characteristics": [ + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 701, + "value": 82, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "unit": "percentage" + }, + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 702, + "value": 0, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 703, + "value": 0, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + } + ] + }, + { + "type": "0000008D-0000-1000-8000-0026BB765291", + "iid": 800, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 801, + "value": "Air Quality", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000095-0000-1000-8000-0026BB765291", + "iid": 802, + "value": 1, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "minValue": 0, + "maxValue": 5, + "minStep": 1 + } + ] + }, + { + "type": "00000082-0000-1000-8000-0026BB765291", + "iid": 900, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 901, + "value": "Humidity", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000010-0000-1000-8000-0026BB765291", + "iid": 902, + "value": 60.099998, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "minValue": 0.0, + "maxValue": 100.0, + "minStep": 1.0, + "unit": "percentage" + } + ] + }, + { + "type": "0000008A-0000-1000-8000-0026BB765291", + "iid": 1000, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 1001, + "value": "Temperature", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 1002, + "value": 24.0, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "minValue": 0.0, + "maxValue": 100.0, + "minStep": 0.1, + "unit": "celsius" + } + ] + }, + { + "type": "00000043-0000-1000-8000-0026BB765291", + "iid": 1100, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 1101, + "value": "Nightlight", + "perms": [ + "pr" + ], + "format": "string" + }, + { + "type": "00000025-0000-1000-8000-0026BB765291", + "iid": 1102, + "value": false, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "bool" + }, + { + "type": "00000008-0000-1000-8000-0026BB765291", + "iid": 1103, + "value": 100, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "int", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "unit": "percentage" + }, + { + "type": "00000013-0000-1000-8000-0026BB765291", + "iid": 1104, + "value": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "float", + "minValue": 0.0, + "maxValue": 360.0, + "minStep": 1.0, + "unit": "arcdegrees" + }, + { + "type": "0000002F-0000-1000-8000-0026BB765291", + "iid": 1105, + "value": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "float", + "minValue": 0.0, + "maxValue": 100.0, + "minStep": 1.0, + "unit": "percentage" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/tests/fixtures/homekit_controller/eve_degree.json b/tests/fixtures/homekit_controller/eve_degree.json new file mode 100644 index 00000000000..2a1217789c4 --- /dev/null +++ b/tests/fixtures/homekit_controller/eve_degree.json @@ -0,0 +1,382 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "string", + "iid": 2, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Degree AA11" + }, + { + "format": "bool", + "iid": 3, + "perms": [ + "pw" + ], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "format": "string", + "iid": 4, + "perms": [ + "pr" + ], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Elgato" + }, + { + "format": "string", + "iid": 5, + "perms": [ + "pr" + ], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Eve Degree 00AAA0000" + }, + { + "format": "string", + "iid": 6, + "perms": [ + "pr" + ], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "AA00A0A00000" + }, + { + "format": "string", + "iid": 7, + "perms": [ + "pr" + ], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "1.2.8" + }, + { + "format": "string", + "iid": 8, + "perms": [ + "pr" + ], + "type": "00000053-0000-1000-8000-0026BB765291", + "value": "1.0.0" + } + ], + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 18, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Battery" + }, + { + "format": "uint8", + "iid": 19, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "00000068-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 65 + }, + { + "format": "uint8", + "iid": 20, + "maxValue": 2, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "0000008F-0000-1000-8000-0026BB765291", + "value": 2 + }, + { + "format": "uint8", + "iid": 21, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "00000079-0000-1000-8000-0026BB765291", + "value": 0 + } + ], + "iid": 17, + "type": "00000096-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 23, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Degree" + }, + { + "format": "float", + "iid": 24, + "maxValue": 100, + "minStep": 0.1, + "minValue": -30, + "perms": [ + "pr", + "ev" + ], + "type": "00000011-0000-1000-8000-0026BB765291", + "unit": "celsius", + "value": 22.77191162109375 + }, + { + "format": "uint8", + "iid": 25, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000036-0000-1000-8000-0026BB765291", + "value": 0 + } + ], + "iid": 22, + "type": "0000008A-0000-1000-8000-0026BB765291", + "primary": true + }, + { + "characteristics": [ + { + "format": "string", + "iid": 28, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Degree" + }, + { + "format": "float", + "iid": 29, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "00000010-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 59.4818115234375 + } + ], + "iid": 27, + "type": "00000082-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 31, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Degree" + }, + { + "format": "float", + "iid": 32, + "maxValue": 1100, + "minStep": 1, + "minValue": 870, + "perms": [ + "pr" + ], + "type": "E863F10F-079E-48FF-8F27-9C2605A29F52", + "value": 1005.7000122070312 + }, + { + "format": "float", + "iid": 33, + "maxValue": 9000, + "minStep": 1, + "minValue": -450, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E863F130-079E-48FF-8F27-9C2605A29F52", + "value": 0 + }, + { + "format": "uint8", + "iid": 34, + "maxValue": 4, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "E863F135-079E-48FF-8F27-9C2605A29F52", + "value": 0 + } + ], + "iid": 30, + "type": "E863F00A-079E-48FF-8F27-9C2605A29F52" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 36, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Logging" + }, + { + "format": "data", + "iid": 37, + "perms": [ + "pr", + "pw" + ], + "type": "E863F11E-079E-48FF-8F27-9C2605A29F52", + "value": "HwABDh4AeAQKAIDVzj5aDMB/" + }, + { + "format": "uint32", + "iid": 38, + "maxValue": 4294967295, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw" + ], + "type": "E863F112-079E-48FF-8F27-9C2605A29F52", + "value": 0 + }, + { + "format": "data", + "iid": 39, + "perms": [ + "pw" + ], + "type": "E863F11C-079E-48FF-8F27-9C2605A29F52" + }, + { + "format": "data", + "iid": 40, + "perms": [ + "pw" + ], + "type": "E863F121-079E-48FF-8F27-9C2605A29F52" + }, + { + "format": "data", + "iid": 41, + "perms": [ + "pr" + ], + "type": "E863F116-079E-48FF-8F27-9C2605A29F52", + "value": "/wkAAJEGAABnvbUmBQECAgIDAh4BJwEGAAAQuvIBAAEAAAABAA==" + }, + { + "format": "data", + "iid": 42, + "perms": [ + "pr" + ], + "type": "E863F117-079E-48FF-8F27-9C2605A29F52", + "value": "" + }, + { + "format": "tlv8", + "iid": 43, + "perms": [ + "pr" + ], + "type": "E863F131-079E-48FF-8F27-9C2605A29F52", + "value": "AAIeAAMCeAQEDFNVMTNHMUEwMDI4MAYCBgAHBLryAQALAgAABQEAAgTwKQAAXwQAAAAAGQIABRQBAw8EAAAAABoEAAAAACUE9griHtJHEAABQEJcLdwpUbihgRCESYX8bA7yLTF6IKhlxv5ohrqDkOEyRTNCM0VDNC1CNENCLTg0MjYtM0Q1QS0zMDJFNEIzRTZERDA=" + }, + { + "format": "tlv8", + "iid": 44, + "perms": [ + "pw" + ], + "type": "E863F11D-079E-48FF-8F27-9C2605A29F52" + } + ], + "iid": 35, + "type": "E863F007-079E-48FF-8F27-9C2605A29F52", + "hidden": true + }, + { + "characteristics": [ + { + "format": "string", + "iid": 100001, + "perms": [ + "pr" + ], + "type": "E863F155-079E-48FF-8F27-9C2605A29F52", + "value": "11:11:11:11:11:11" + }, + { + "format": "uint16", + "iid": 100002, + "perms": [ + "pr" + ], + "type": "E863F156-079E-48FF-8F27-9C2605A29F52", + "value": 10 + }, + { + "format": "uint8", + "iid": 100003, + "perms": [ + "pr", + "ev" + ], + "type": "E863F157-079E-48FF-8F27-9C2605A29F52", + "value": 1 + } + ], + "hidden": true, + "iid": 100000, + "type": "E863F00B-079E-48FF-8F27-9C2605A29F52" + } + ] + } +] \ No newline at end of file diff --git a/tests/fixtures/myq/devices.json b/tests/fixtures/myq/devices.json index f7c65c6bb20..1e731ffe204 100644 --- a/tests/fixtures/myq/devices.json +++ b/tests/fixtures/myq/devices.json @@ -1,5 +1,5 @@ { - "count" : 4, + "count" : 6, "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices", "items" : [ { @@ -128,6 +128,36 @@ "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial", "device_type" : "wifigaragedooropener", "created_date" : "2020-02-10T23:11:47.487" - } + }, + { + "serial_number" : "garage_light_off", + "state" : { + "last_status" : "2020-03-30T02:48:45.7501595Z", + "online" : true, + "lamp_state" : "off", + "last_update" : "2020-03-26T15:45:31.4713796Z" + }, + "parent_device_id" : "gateway_serial", + "device_platform" : "myq", + "name" : "Garage Door Light Off", + "device_family" : "lamp", + "device_type" : "lamp", + "created_date" : "2020-02-10T23:11:47.487" + }, + { + "serial_number" : "garage_light_on", + "state" : { + "last_status" : "2020-03-30T02:48:45.7501595Z", + "online" : true, + "lamp_state" : "on", + "last_update" : "2020-03-26T15:45:31.4713796Z" + }, + "parent_device_id" : "gateway_serial", + "device_platform" : "myq", + "name" : "Garage Door Light On", + "device_family" : "lamp", + "device_type" : "lamp", + "created_date" : "2020-02-10T23:11:47.487" + } ] } diff --git a/tests/fixtures/mysensors/distance_sensor_state.json b/tests/fixtures/mysensors/distance_sensor_state.json new file mode 100644 index 00000000000..ff8b246b880 --- /dev/null +++ b/tests/fixtures/mysensors/distance_sensor_state.json @@ -0,0 +1,22 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 15, + "description": "", + "values": { + "13": "15", + "43": "cm" + } + } + }, + "type": 17, + "sketch_name": "Distance Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/fixtures/mysensors/energy_sensor_state.json b/tests/fixtures/mysensors/energy_sensor_state.json new file mode 100644 index 00000000000..063083c9c1e --- /dev/null +++ b/tests/fixtures/mysensors/energy_sensor_state.json @@ -0,0 +1,21 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 13, + "description": "", + "values": { + "18": "18000" + } + } + }, + "type": 17, + "sketch_name": "Energy Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/fixtures/mysensors/sound_sensor_state.json b/tests/fixtures/mysensors/sound_sensor_state.json new file mode 100644 index 00000000000..35651243250 --- /dev/null +++ b/tests/fixtures/mysensors/sound_sensor_state.json @@ -0,0 +1,21 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 33, + "description": "", + "values": { + "37": "10" + } + } + }, + "type": 17, + "sketch_name": "Sound Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/fixtures/mysensors/temperature_sensor_state.json b/tests/fixtures/mysensors/temperature_sensor_state.json new file mode 100644 index 00000000000..4367be6a3cd --- /dev/null +++ b/tests/fixtures/mysensors/temperature_sensor_state.json @@ -0,0 +1,21 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 6, + "description": "", + "values": { + "0": "20.0" + } + } + }, + "type": 17, + "sketch_name": "Temperature Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/fixtures/p1_monitor/phases.json b/tests/fixtures/p1_monitor/phases.json new file mode 100644 index 00000000000..b756f092c05 --- /dev/null +++ b/tests/fixtures/p1_monitor/phases.json @@ -0,0 +1,74 @@ +[ + { + "LABEL": "Huidige KW verbruik L1 (21.7.0)", + "SECURITY": 0, + "STATUS": "0.315", + "STATUS_ID": 74 + }, + { + "LABEL": "Huidige KW verbruik L2 (41.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 75 + }, + { + "LABEL": "Huidige KW verbruik L3 (61.7.0)", + "SECURITY": 0, + "STATUS": "0.624", + "STATUS_ID": 76 + }, + { + "LABEL": "Huidige KW levering L1 (22.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 77 + }, + { + "LABEL": "Huidige KW levering L2 (42.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 78 + }, + { + "LABEL": "Huidige KW levering L3 (62.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 79 + }, + { + "LABEL": "Huidige Amperage L1 (31.7.0)", + "SECURITY": 0, + "STATUS": "1.6", + "STATUS_ID": 100 + }, + { + "LABEL": "Huidige Amperage L2 (51.7.0)", + "SECURITY": 0, + "STATUS": "4.44", + "STATUS_ID": 101 + }, + { + "LABEL": "Huidige Amperage L2 (71.7.0)", + "SECURITY": 0, + "STATUS": "3.51", + "STATUS_ID": 102 + }, + { + "LABEL": "Huidige Voltage L1 (32.7.0)", + "SECURITY": 0, + "STATUS": "233.6", + "STATUS_ID": 103 + }, + { + "LABEL": "Huidige Voltage L2 (52.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 104 + }, + { + "LABEL": "Huidige Voltage L2 (72.7.0)", + "SECURITY": 0, + "STATUS": "233.0", + "STATUS_ID": 105 + } +] \ No newline at end of file diff --git a/tests/fixtures/p1_monitor/settings.json b/tests/fixtures/p1_monitor/settings.json new file mode 100644 index 00000000000..eaa14765566 --- /dev/null +++ b/tests/fixtures/p1_monitor/settings.json @@ -0,0 +1,27 @@ +[ + { + "CONFIGURATION_ID": 1, + "LABEL": "Verbruik tarief elektriciteit dal/nacht in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 2, + "LABEL": "Verbruik tarief elektriciteit piek/dag in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 3, + "LABEL": "Geleverd tarief elektriciteit dal/nacht in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 4, + "LABEL": "Geleverd tarief elektriciteit piek/dag in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 15, + "LABEL": "Verbruik tarief gas in euro.", + "PARAMETER": "0.64" + } +] \ No newline at end of file diff --git a/tests/fixtures/p1_monitor/smartmeter.json b/tests/fixtures/p1_monitor/smartmeter.json new file mode 100644 index 00000000000..d2ca0b38002 --- /dev/null +++ b/tests/fixtures/p1_monitor/smartmeter.json @@ -0,0 +1,15 @@ +[ + { + "CONSUMPTION_GAS_M3": 2273.447, + "CONSUMPTION_KWH_HIGH": 2770.133, + "CONSUMPTION_KWH_LOW": 4988.071, + "CONSUMPTION_W": 877, + "PRODUCTION_KWH_HIGH": 3971.604, + "PRODUCTION_KWH_LOW": 1432.279, + "PRODUCTION_W": 0, + "RECORD_IS_PROCESSED": 0, + "TARIFCODE": "P", + "TIMESTAMP_UTC": 1629134632, + "TIMESTAMP_lOCAL": "2021-08-16 19:23:52" + } +] \ No newline at end of file diff --git a/tests/fixtures/plex/plextv_resources_base.xml b/tests/fixtures/plex/plextv_resources_base.xml index 41e61711d36..5802c58d4d4 100644 --- a/tests/fixtures/plex/plextv_resources_base.xml +++ b/tests/fixtures/plex/plextv_resources_base.xml @@ -1,5 +1,5 @@ - + diff --git a/tests/fixtures/renault/no_data.json b/tests/fixtures/renault/no_data.json new file mode 100644 index 00000000000..7b78844ca99 --- /dev/null +++ b/tests/fixtures/renault/no_data.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": {} + } +} diff --git a/tests/fixtures/zwave_js/cover_qubino_shutter_state.json b/tests/fixtures/zwave_js/cover_qubino_shutter_state.json index 65725606e1c..bde7c90e1e4 100644 --- a/tests/fixtures/zwave_js/cover_qubino_shutter_state.json +++ b/tests/fixtures/zwave_js/cover_qubino_shutter_state.json @@ -1,48 +1,104 @@ { - "nodeId": 5, + "nodeId": 20, "index": 0, "installerIcon": 6656, "userIcon": 6656, "status": 4, "ready": true, - "deviceClass": { - "basic": { "key": 4, "label": "Routing Slave" }, - "generic": { "key": 17, "label": "Routing Slave" }, - "specific": { "key": 7, "label": "Routing Slave" }, - "mandatorySupportedCCs": [], - "mandatoryControlCCs": [] - }, "isListening": true, - "isFrequentListening": false, "isRouting": true, - "maxBaudRate": 40000, "isSecure": false, - "version": 4, - "isBeaming": true, "manufacturerId": 345, - "productId": 83, + "productId": 82, "productType": 3, - "firmwareVersion": "7.2", + "firmwareVersion": "71.0", "zwavePlusVersion": 1, - "nodeType": 0, - "roleType": 5, "deviceConfig": { - "manufacturerId": 345, + "filename": "/data/db/devices/0x0159/zmnhcd_4.1.json", + "isEmbedded": true, "manufacturer": "Qubino", - "label": "ZMNHOD", - "description": "Flush Shutter DC", - "devices": [{ "productType": "0x0003", "productId": "0x0053" }], - "firmwareVersion": { "min": "0.0", "max": "255.255" }, - "paramInformation": { "_map": {} } + "manufacturerId": 345, + "label": "ZMNHCD", + "description": "Flush Shutter", + "devices": [ + { + "productType": 3, + "productId": 82 + } + ], + "firmwareVersion": { + "min": "4.1", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } }, - "label": "ZMNHOD", - "neighbors": [1, 2], - "interviewAttempts": 1, + "label": "ZMNHCD", + "interviewAttempts": 0, "endpoints": [ - { "nodeId": 5, "index": 0, "installerIcon": 6656, "userIcon": 6656 } + { + "nodeId": 20, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 37, + 114, + 134 + ], + "mandatoryControlledCCs": [] + } + } ], - "commandClasses": [], "values": [ + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ] + } + }, { "endpoint": 0, "commandClass": 38, @@ -54,10 +110,14 @@ "type": "number", "readable": true, "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, - "max": 99, - "label": "Target value" - } + "max": 99 + }, + "value": 99 }, { "endpoint": 0, @@ -84,11 +144,11 @@ "type": "number", "readable": true, "writeable": false, + "label": "Current value", "min": 0, - "max": 99, - "label": "Current value" + "max": 99 }, - "value": "unknown" + "value": 0 }, { "endpoint": 0, @@ -102,7 +162,9 @@ "readable": true, "writeable": true, "label": "Perform a level change (Up)", - "ccSpecific": { "switchType": 2 } + "ccSpecific": { + "switchType": 2 + } } }, { @@ -117,146 +179,9 @@ "readable": true, "writeable": true, "label": "Perform a level change (Down)", - "ccSpecific": { "switchType": 2 } - } - }, - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value" - }, - "value": "unknown" - }, - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value" - } - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "manufacturerId", - "propertyName": "manufacturerId", - "ccVersion": 2, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 65535, - "label": "Manufacturer ID" - }, - "value": 345 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productType", - "propertyName": "productType", - "ccVersion": 2, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 65535, - "label": "Product type" - }, - "value": 3 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productId", - "propertyName": "productId", - "ccVersion": 2, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 65535, - "label": "Product ID" - }, - "value": 83 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "libraryType", - "propertyName": "libraryType", - "ccVersion": 2, - "metadata": { - "type": "any", - "readable": true, - "writeable": false, - "label": "Library type" - }, - "value": 3 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "protocolVersion", - "propertyName": "protocolVersion", - "ccVersion": 2, - "metadata": { - "type": "any", - "readable": true, - "writeable": false, - "label": "Z-Wave protocol version" - }, - "value": "4.38" - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "firmwareVersions", - "propertyName": "firmwareVersions", - "ccVersion": 2, - "metadata": { - "type": "any", - "readable": true, - "writeable": false, - "label": "Z-Wave chip firmware versions" - }, - "value": ["7.2"] - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "hardwareVersion", - "propertyName": "hardwareVersion", - "ccVersion": 2, - "metadata": { - "type": "any", - "readable": true, - "writeable": false, - "label": "Z-Wave chip hardware version" + "ccSpecific": { + "switchType": 2 + } } }, { @@ -273,29 +198,14 @@ "readable": true, "writeable": false, "label": "Electric Consumed [kWh]", - "unit": "kWh", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } + "ccSpecific": { + "meterType": 1, + "rateType": 1, + "scale": 0 + }, + "unit": "kWh" }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 50, - "commandClassName": "Meter", - "property": "deltaTime", - "propertyKey": 65537, - "propertyName": "deltaTime", - "propertyKeyName": "Electric_kWh_Consumed", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Electric Consumed [kWh] (prev. time delta)", - "unit": "s", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } - }, - "value": 0 + "value": 7.9 }, { "endpoint": 0, @@ -311,27 +221,12 @@ "readable": true, "writeable": false, "label": "Electric Consumed [W]", - "unit": "W", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 50, - "commandClassName": "Meter", - "property": "deltaTime", - "propertyKey": 66049, - "propertyName": "deltaTime", - "propertyKeyName": "Electric_W_Consumed", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Electric Consumed [W] (prev. time delta)", - "unit": "s", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } + "ccSpecific": { + "meterType": 1, + "rateType": 1, + "scale": 2 + }, + "unit": "W" }, "value": 0 }, @@ -349,119 +244,31 @@ "label": "Reset accumulated values" } }, - { - "endpoint": 0, - "commandClass": 50, - "commandClassName": "Meter", - "property": "previousValue", - "propertyKey": 65537, - "propertyName": "previousValue", - "propertyKeyName": "Electric_kWh_Consumed", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Electric Consumed [kWh] (prev. value)", - "unit": "kWh", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 0 } - } - }, - { - "endpoint": 0, - "commandClass": 50, - "commandClassName": "Meter", - "property": "previousValue", - "propertyKey": 66049, - "propertyName": "previousValue", - "propertyKeyName": "Electric_W_Consumed", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Electric Consumed [W] (prev. value)", - "unit": "W", - "ccSpecific": { "meterType": 1, "rateType": 1, "scale": 2 } - } - }, - { - "endpoint": 0, - "commandClass": 113, - "commandClassName": "Notification", - "property": "alarmType", - "propertyName": "alarmType", - "ccVersion": 5, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 255, - "label": "Alarm Type" - } - }, - { - "endpoint": 0, - "commandClass": 113, - "commandClassName": "Notification", - "property": "alarmLevel", - "propertyName": "alarmLevel", - "ccVersion": 5, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 255, - "label": "Alarm Level" - } - }, - { - "endpoint": 0, - "commandClass": 113, - "commandClassName": "Notification", - "property": "Power Management", - "propertyKey": "Over-load status", - "propertyName": "Power Management", - "propertyKeyName": "Over-load status", - "ccVersion": 5, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "min": 0, - "max": 255, - "label": "Over-load status", - "states": { "0": "idle", "8": "Over-load detected" }, - "ccSpecific": { "notificationType": 8 } - }, - "value": 0 - }, { "endpoint": 0, "commandClass": 112, "commandClassName": "Configuration", "property": 10, - "propertyName": "Activate/deactivate functions ALL ON / ALL OFF", + "propertyName": "ALL ON/ALL OFF", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 2, - "min": 0, - "max": 65535, + "description": "Responds to commands ALL ON / ALL OFF from Main Controller", + "label": "ALL ON/ALL OFF", "default": 255, - "format": 1, - "allowManualEntry": false, + "min": 0, + "max": 255, "states": { - "0": "ALL ON is not active, ALL OFF is not active", + "0": "ALL ON is not active ALL OFF is not active", "1": "ALL ON is not active ALL OFF active", "2": "ALL ON is not active ALL OFF is not active", "255": "ALL ON active, ALL OFF active" }, - "label": "Activate/deactivate functions ALL ON / ALL OFF", + "valueSize": 2, + "format": 0, + "allowManualEntry": false, "isFromConfig": true }, "value": 255 @@ -471,19 +278,20 @@ "commandClass": 112, "commandClassName": "Configuration", "property": 40, - "propertyName": "Power report (Watts) on power change for Q1 or Q2", + "propertyName": "Power reporting in watts on power change", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Power consumption change threshold for sending updates", + "label": "Power reporting in watts on power change", + "default": 1, "min": 0, "max": 100, - "default": 1, + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Power report (Watts) on power change for Q1 or Q2", "isFromConfig": true }, "value": 10 @@ -493,19 +301,20 @@ "commandClass": 112, "commandClassName": "Configuration", "property": 42, - "propertyName": "Power report (Watts) by time interval for Q1 or Q2", + "propertyName": "Power reporting in Watts by time interval", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 2, + "label": "Power reporting in Watts by time interval", + "default": 300, "min": 0, "max": 32767, - "default": 300, + "unit": "seconds", + "valueSize": 2, "format": 0, "allowManualEntry": true, - "label": "Power report (Watts) by time interval for Q1 or Q2", "isFromConfig": true }, "value": 0 @@ -521,17 +330,18 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Operation Mode (Shutter or Venetian)", + "label": "Operating modes", + "default": 0, "min": 0, "max": 255, - "default": 0, - "format": 1, - "allowManualEntry": false, "states": { - "0": "Shutter mode.", + "0": "Shutter mode", "1": "Venetian mode (up/down and slate rotation)" }, - "label": "Operating modes", + "valueSize": 1, + "format": 1, + "allowManualEntry": false, "isFromConfig": true }, "value": 0 @@ -547,16 +357,18 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 2, + "description": "Slat full turn time in tenths of a second.", + "label": "Slats tilting full turn time", + "default": 150, "min": 0, "max": 32767, - "default": 150, + "unit": "tenths of a second", + "valueSize": 2, "format": 0, "allowManualEntry": true, - "label": "Slats tilting full turn time", "isFromConfig": true }, - "value": 630 + "value": 150 }, { "endpoint": 0, @@ -569,43 +381,22 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, - "min": 0, - "max": 255, - "default": 1, - "format": 1, - "allowManualEntry": false, - "states": { - "0": "Return to previous position only with Z-wave", - "1": "Return to previous position with Z-wave or button" - }, + "description": "Slats position after up/down movement.", "label": "Slats position", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Previous position for Z-wave control only", + "1": "Return to previous position in all cases" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, "isFromConfig": true }, "value": 1 }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 74, - "propertyName": "Motor moving up/down time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "valueSize": 2, - "min": 0, - "max": 32767, - "default": 0, - "format": 0, - "allowManualEntry": true, - "label": "Motor moving up/down time", - "isFromConfig": true - }, - "value": 0 - }, { "endpoint": 0, "commandClass": 112, @@ -617,36 +408,41 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Power threshold to be interpreted when motor reach the limit switch", + "label": "Motor operation detection", + "default": 10, "min": 0, - "max": 100, - "default": 6, + "max": 127, + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Motor operation detection", "isFromConfig": true }, - "value": 10 + "value": 30 }, { "endpoint": 0, "commandClass": 112, "commandClassName": "Configuration", "property": 78, - "propertyName": "Forced Shutter DC calibration", + "propertyName": "Forced Shutter calibration", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 1, - "min": 0, - "max": 255, + "description": "Enters calibration mode if set to 1", + "label": "Forced Shutter calibration", "default": 0, - "format": 1, + "min": 0, + "max": 1, + "states": { + "0": "Default", + "1": "Start Calibration Process" + }, + "valueSize": 1, + "format": 0, "allowManualEntry": false, - "states": { "0": "Default", "1": "Start calibration process." }, - "label": "Forced Shutter DC calibration", "isFromConfig": true }, "value": 0 @@ -662,57 +458,38 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, - "min": 3, - "max": 50, - "default": 8, - "format": 0, - "allowManualEntry": true, + "description": "Time delay for detecting motor errors", "label": "Power consumption max delay time", - "isFromConfig": true - }, - "value": 8 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 86, - "propertyName": "Power consumption at limit switch delay time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "valueSize": 1, - "min": 3, - "max": 50, "default": 8, + "min": 0, + "max": 50, + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Power consumption at limit switch delay time", "isFromConfig": true }, - "value": 8 + "value": 30 }, { "endpoint": 0, "commandClass": 112, "commandClassName": "Configuration", "property": 90, - "propertyName": "Time delay for next motor movement", + "propertyName": "Relay delay time", "ccVersion": 1, "metadata": { "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Defines the minimum time delay between next motor movement", + "label": "Relay delay time", + "default": 5, "min": 1, "max": 30, - "default": 5, + "unit": "milliseconds", + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Time delay for next motor movement", "isFromConfig": true }, "value": 5 @@ -728,13 +505,14 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 2, + "description": "Adds or removes an offset from the measured temperature.", + "label": "Temperature sensor offset settings", + "default": 32536, "min": 1, "max": 32536, - "default": 32536, + "valueSize": 2, "format": 0, "allowManualEntry": true, - "label": "Temperature sensor offset settings", "isFromConfig": true }, "value": 32536 @@ -750,16 +528,373 @@ "type": "number", "readable": true, "writeable": true, - "valueSize": 1, + "description": "Threshold for sending temperature change reports", + "label": "Digital temperature sensor reporting", + "default": 5, "min": 0, "max": 127, - "default": 5, + "valueSize": 1, "format": 0, "allowManualEntry": true, - "label": "Digital temperature sensor reporting", "isFromConfig": true }, "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 74, + "propertyName": "Motor moving up/down time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Shutter motor moving time of complete opening or complete closing", + "label": "Motor moving up/down time", + "default": 0, + "min": 0, + "max": 32767, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 80, + "propertyName": "Reporting to Controller", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Defines if reporting regarding power level, etc is reported to controller.", + "label": "Reporting to Controller", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Reporting to Controller Disabled", + "1": "Reporting to Controller Enabled" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 86, + "propertyName": "Power consumption at limit switch delay time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the time delay for detecting limit switches", + "label": "Power consumption at limit switch delay time", + "default": 8, + "min": 3, + "max": 50, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "unknown", + "propertyName": "Power Management", + "propertyKeyName": "unknown", + "ccVersion": 5, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + }, + "value": 254 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-load status", + "propertyName": "Power Management", + "propertyKeyName": "Over-load status", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-load status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Over-load detected" + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 345 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 82 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.38" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "71.0", + "71.0" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 2 } - ] + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 37, + 114, + 134 + ], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 3, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 4, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 5, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0159:0x0003:0x0052:71.0", + "statistics": { + "commandsTX": 17, + "commandsRX": 57, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + } } diff --git a/tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json b/tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json new file mode 100644 index 00000000000..2b4a3a88984 --- /dev/null +++ b/tests/fixtures/zwave_js/lock_popp_electric_strike_lock_control_state.json @@ -0,0 +1,568 @@ +{ + "nodeId": 62, + "index": 0, + "installerIcon": 768, + "userIcon": 768, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 340, + "productId": 1, + "productType": 5, + "firmwareVersion": "1.3", + "zwavePlusVersion": 1, + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 62, + "index": 0, + "installerIcon": 768, + "userIcon": 768, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 10, + "label": "Lockbox" + }, + "mandatorySupportedCCs": [113, 133, 98, 114, 152, 134], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 48, + "commandClassName": "Binary Sensor", + "property": "Door/Window", + "propertyName": "Door/Window", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Door/Window", + "ccSpecific": { + "sensorType": 10 + } + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "currentMode", + "propertyName": "currentMode", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current lock mode", + "min": 0, + "max": 255, + "states": { + "0": "Unsecured", + "1": "UnsecuredWithTimeout", + "16": "InsideUnsecured", + "17": "InsideUnsecuredWithTimeout", + "32": "OutsideUnsecured", + "33": "OutsideUnsecuredWithTimeout", + "254": "Unknown", + "255": "Secured" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "targetMode", + "propertyName": "targetMode", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target lock mode", + "min": 0, + "max": 255, + "states": { + "0": "Unsecured", + "1": "UnsecuredWithTimeout", + "16": "InsideUnsecured", + "17": "InsideUnsecuredWithTimeout", + "32": "OutsideUnsecured", + "33": "OutsideUnsecuredWithTimeout", + "254": "Unknown", + "255": "Secured" + } + } + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "outsideHandlesCanOpenDoor", + "propertyName": "outsideHandlesCanOpenDoor", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Which outside handles can open the door (actual status)" + }, + "value": [false, false, false, false] + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "insideHandlesCanOpenDoor", + "propertyName": "insideHandlesCanOpenDoor", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Which inside handles can open the door (actual status)" + }, + "value": [false, false, false, false] + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "latchStatus", + "propertyName": "latchStatus", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "The current status of the latch" + }, + "value": "closed" + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "boltStatus", + "propertyName": "boltStatus", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "The current status of the bolt" + }, + "value": "unlocked" + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "doorStatus", + "propertyName": "doorStatus", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "The current status of the door" + }, + "value": "closed" + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "lockTimeout", + "propertyName": "lockTimeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Seconds until lock mode times out" + } + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "operationType", + "propertyName": "operationType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Lock operation type", + "min": 0, + "max": 255, + "states": { + "1": "Constant", + "2": "Timed" + } + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "outsideHandlesCanOpenDoorConfiguration", + "propertyName": "outsideHandlesCanOpenDoorConfiguration", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Which outside handles can open the door (configuration)" + }, + "value": [false, false, false, false] + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "insideHandlesCanOpenDoorConfiguration", + "propertyName": "insideHandlesCanOpenDoorConfiguration", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Which inside handles can open the door (configuration)" + }, + "value": [false, false, false, false] + }, + { + "endpoint": 0, + "commandClass": 98, + "commandClassName": "Door Lock", + "property": "lockTimeoutConfiguration", + "propertyName": "lockTimeoutConfiguration", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Duration of timed mode in seconds", + "min": 0, + "max": 65535 + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Door state", + "propertyName": "Access Control", + "propertyKeyName": "Door state", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Door state", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "22": "Window/door is open", + "23": "Window/door is closed" + } + }, + "value": 23 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 340 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.5" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["1.3"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 1 + } + ], + "isFrequentListening": "1000ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 10, + "label": "Lockbox" + }, + "mandatorySupportedCCs": [113, 133, 98, 114, 152, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 48, + "name": "Binary Sensor", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": true + }, + { + "id": 98, + "name": "Door Lock", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 5, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 3, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0154:0x0005:0x0001:1.3", + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + } +} diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py new file mode 100644 index 00000000000..c65716d5d92 --- /dev/null +++ b/tests/hassfest/test_requirements.py @@ -0,0 +1,68 @@ +"""Tests for hassfest requirements.""" +from pathlib import Path + +import pytest + +from script.hassfest.model import Integration +from script.hassfest.requirements import validate_requirements_format + + +@pytest.fixture +def integration(): + """Fixture for hassfest integration model.""" + integration = Integration( + path=Path("homeassistant/components/test"), + manifest={ + "domain": "test", + "documentation": "https://example.com", + "name": "test", + "codeowners": ["@awesome"], + "requirements": [], + }, + ) + yield integration + + +def test_validate_requirements_format_with_space(integration: Integration): + """Test validate requirement with space around separator.""" + integration.manifest["requirements"] = ["test_package == 1"] + assert not validate_requirements_format(integration) + assert len(integration.errors) == 1 + assert 'Requirement "test_package == 1" contains a space' in [ + x.error for x in integration.errors + ] + + +def test_validate_requirements_format_wrongly_pinned(integration: Integration): + """Test requirement with loose pin.""" + integration.manifest["requirements"] = ["test_package>=1"] + assert not validate_requirements_format(integration) + assert len(integration.errors) == 1 + assert 'Requirement test_package>=1 need to be pinned "==".' in [ + x.error for x in integration.errors + ] + + +def test_validate_requirements_format_ignore_pin_for_custom(integration: Integration): + """Test requirement ignore pinning for custom.""" + integration.manifest["requirements"] = ["test_package>=1"] + integration.path = Path("") + assert validate_requirements_format(integration) + assert len(integration.errors) == 0 + + +def test_validate_requirements_format_invalid_version(integration: Integration): + """Test requirement with invalid version.""" + integration.manifest["requirements"] = ["test_package==invalid"] + assert not validate_requirements_format(integration) + assert len(integration.errors) == 1 + assert "Unable to parse package version (invalid) for test_package." in [ + x.error for x in integration.errors + ] + + +def test_validate_requirements_format_successful(integration: Integration): + """Test requirement with successful result.""" + integration.manifest["requirements"] = ["test_package==1.2.3"] + assert validate_requirements_format(integration) + assert len(integration.errors) == 0 diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index c5e9f5880c4..cf832dfde50 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -120,6 +120,25 @@ def test_url(): assert schema(value) +def test_url_no_path(): + """Test URL.""" + schema = vol.Schema(cv.url_no_path) + + for value in ( + "https://localhost/test/index.html", + "http://home-assistant.io/test/", + ): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ( + "http://localhost", + "http://home-assistant.io", + "https://community.home-assistant.io/", + ): + assert schema(value) + + def test_platform_config(): """Test platform config validation.""" options = ({}, {"hello": "world"}) @@ -369,6 +388,34 @@ def test_service_schema(): cv.SERVICE_SCHEMA(value) +def test_entity_service_schema(): + """Test make_entity_service_schema validation.""" + schema = cv.make_entity_service_schema( + {vol.Required("required"): cv.positive_int, vol.Optional("optional"): cv.string} + ) + + options = ( + {}, + None, + {"entity_id": "light.kitchen"}, + {"optional": "value", "entity_id": "light.kitchen"}, + {"required": 1}, + {"required": 2, "area_id": "kitchen", "foo": "bar"}, + {"required": "str", "area_id": "kitchen"}, + ) + for value in options: + with pytest.raises(vol.MultipleInvalid): + cv.SERVICE_SCHEMA(value) + + options = ( + {"required": 1, "entity_id": "light.kitchen"}, + {"required": 2, "optional": "value", "device_id": "a_device"}, + {"required": 3, "area_id": "kitchen"}, + ) + for value in options: + schema(value) + + def test_slug(): """Test slug validation.""" schema = vol.Schema(cv.slug) @@ -893,7 +940,7 @@ def test_has_at_most_one_key(): with pytest.raises(vol.MultipleInvalid): schema(value) - for value in ({}, {"beer": None}, {"soda": None}): + for value in ({}, {"beer": None}, {"soda": None}, {vol.Optional("soda"): None}): schema(value) @@ -905,7 +952,7 @@ def test_has_at_least_one_key(): with pytest.raises(vol.MultipleInvalid): schema(value) - for value in ({"beer": None}, {"soda": None}): + for value in ({"beer": None}, {"soda": None}, {vol.Required("soda"): None}): schema(value) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 1d3be2ca98d..d138a5381da 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -98,6 +98,62 @@ async def test_periodic_write(hass): assert not mock_write_data.called +async def test_save_persistent_states(hass): + """Test that we cancel the currently running job, save the data, and verify the perdiodic job continues.""" + data = await RestoreStateData.async_get_instance(hass) + await hass.async_block_till_done() + await data.store.async_save([]) + + # Emulate a fresh load + hass.data[DATA_RESTORE_STATE_TASK] = None + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = "input_boolean.b1" + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + await entity.async_get_last_state() + await hass.async_block_till_done() + + # Startup Save + assert mock_write_data.called + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + # Not quite the first interval + assert not mock_write_data.called + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + await RestoreStateData.async_save_persistent_states(hass) + await hass.async_block_till_done() + + assert mock_write_data.called + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) + await hass.async_block_till_done() + # Verify still saving + assert mock_write_data.called + + with patch( + "homeassistant.helpers.restore_state.Store.async_save" + ) as mock_write_data: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + # Verify normal shutdown + assert mock_write_data.called + + async def test_hass_starting(hass): """Test that we cache data.""" hass.state = CoreState.starting diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d6fe2b6dbaf..64b075b685a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -23,7 +23,12 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import UnitSystem -from tests.common import MockConfigEntry, mock_device_registry, mock_registry +from tests.common import ( + MockConfigEntry, + mock_area_registry, + mock_device_registry, + mock_registry, +) def _set_up_units(hass): @@ -1513,7 +1518,7 @@ async def test_expand(hass): async def test_device_entities(hass): - """Test expand function.""" + """Test device_entities function.""" config_entry = MockConfigEntry(domain="light") device_registry = mock_device_registry(hass) entity_registry = mock_registry(hass) @@ -1730,6 +1735,189 @@ async def test_device_attr(hass): assert info.rate_limit is None +async def test_area_id(hass): + """Test area_id function.""" + config_entry = MockConfigEntry(domain="light") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + area_registry = mock_area_registry(hass) + + # Test non existing entity id + info = render_to_info(hass, "{{ area_id('sensor.fake') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing device id (hex value) + info = render_to_info(hass, "{{ area_id('123abc') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing area name + info = render_to_info(hass, "{{ area_id('fake area name') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ area_id(56) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + area_entry_entity_id = area_registry.async_get_or_create("sensor.fake") + + # Test device with single entity, which has no area + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ area_id('{device_entry.id}') }}}}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device ID, entity ID and area name as input with area name that looks like + # a device ID. Try a filter too + area_entry_hex = area_registry.async_get_or_create("123abc") + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry_hex.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_hex.id + ) + + info = render_to_info(hass, f"{{{{ '{device_entry.id}' | area_id }}}}") + assert_result_info(info, area_entry_hex.id) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry_hex.id) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{area_entry_hex.name}') }}}}") + assert_result_info(info, area_entry_hex.id) + assert info.rate_limit is None + + # Test device ID, entity ID and area name as input with area name that looks like an + # entity ID + area_entry_entity_id = area_registry.async_get_or_create("sensor.fake") + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry_entity_id.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_entity_id.id + ) + + info = render_to_info(hass, f"{{{{ area_id('{device_entry.id}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_id('{area_entry_entity_id.name}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_entity_id.id + ) + + info = render_to_info(hass, f"{{{{ area_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry_entity_id.id) + assert info.rate_limit is None + + +async def test_area_name(hass): + """Test area_name function.""" + config_entry = MockConfigEntry(domain="light") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + area_registry = mock_area_registry(hass) + + # Test non existing entity id + info = render_to_info(hass, "{{ area_name('sensor.fake') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing device id (hex value) + info = render_to_info(hass, "{{ area_name('123abc') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing area id + info = render_to_info(hass, "{{ area_name('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ area_name(56) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device with single entity, which has no area + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ area_name('{device_entry.id}') }}}}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device ID, entity ID and area id as input. Try a filter too + area_entry = area_registry.async_get_or_create("123abc") + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry.id + ) + + info = render_to_info(hass, f"{{{{ '{device_entry.id}' | area_name }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ area_name('{area_entry.id}') }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=None + ) + + info = render_to_info(hass, f"{{{{ area_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, area_entry.name) + assert info.rate_limit is None + + def test_closest_function_to_coord(hass): """Test closest function to coord.""" hass.states.async_set( diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index b4bfb881186..7afdb629792 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1,8 +1,13 @@ """The tests for the trigger helper.""" +from unittest.mock import MagicMock, call, patch + import pytest import voluptuous as vol -from homeassistant.helpers.trigger import async_validate_trigger_config +from homeassistant.helpers.trigger import ( + _async_get_trigger_platform, + async_validate_trigger_config, +) async def test_bad_trigger_platform(hass): @@ -10,3 +15,12 @@ async def test_bad_trigger_platform(hass): with pytest.raises(vol.Invalid) as ex: await async_validate_trigger_config(hass, [{"platform": "not_a_platform"}]) assert "Invalid platform 'not_a_platform' specified" in str(ex) + + +async def test_trigger_subtype(hass): + """Test trigger subtypes.""" + with patch( + "homeassistant.helpers.trigger.async_get_integration", return_value=MagicMock() + ) as integration_mock: + await _async_get_trigger_platform(hass, {"platform": "test.subtype"}) + assert integration_mock.call_args == call(hass, "test") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1fecf7be96b..929cbbf6e81 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,6 +1,7 @@ """Test the bootstrapping.""" # pylint: disable=protected-access import asyncio +import glob import os from unittest.mock import Mock, patch @@ -62,6 +63,17 @@ async def test_async_enable_logging(hass): ) as mock_async_activate_log_queue_handler: bootstrap.async_enable_logging(hass) mock_async_activate_log_queue_handler.assert_called_once() + mock_async_activate_log_queue_handler.reset_mock() + bootstrap.async_enable_logging( + hass, + log_rotate_days=5, + log_file="test.log", + ) + mock_async_activate_log_queue_handler.assert_called_once() + for f in glob.glob("test.log*"): + os.remove(f) + for f in glob.glob("testing_config/home-assistant.log*"): + os.remove(f) async def test_load_hassio(hass): diff --git a/tests/test_config.py b/tests/test_config.py index 96196c943aa..441029d27dc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -215,6 +215,19 @@ def test_core_config_schema(): ) +def test_core_config_schema_internal_external_warning(caplog): + """Test that we warn for internal/external URL with path.""" + config_util.CORE_CONFIG_SCHEMA( + { + "external_url": "https://www.example.com/bla", + "internal_url": "http://example.local/yo", + } + ) + + assert "Invalid external_url set" in caplog.text + assert "Invalid internal_url set" in caplog.text + + def test_customize_dict_schema(): """Test basic customize config validation.""" values = ({ATTR_FRIENDLY_NAME: None}, {ATTR_ASSUMED_STATE: "2"}) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7bcc83048a4..2ae4ad036d4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2359,7 +2359,9 @@ async def test_async_setup_update_entry(hass): ( config_entries.SOURCE_DISCOVERY, config_entries.SOURCE_SSDP, + config_entries.SOURCE_USB, config_entries.SOURCE_HOMEKIT, + config_entries.SOURCE_DHCP, config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HASSIO, ), @@ -2583,6 +2585,74 @@ async def test_default_discovery_abort_on_user_flow_complete(hass, manager): assert len(flows) == 0 +async def test_flow_same_device_multiple_sources(hass, manager): + """Test discovery of the same devices from multiple discovery sources.""" + mock_integration( + hass, + MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_zeroconf(self, discovery_info=None): + """Test zeroconf step.""" + return await self._async_discovery_handler(discovery_info) + + async def async_step_homekit(self, discovery_info=None): + """Test homekit step.""" + return await self._async_discovery_handler(discovery_info) + + async def _async_discovery_handler(self, discovery_info=None): + """Test any discovery handler.""" + await self.async_set_unique_id("thisid") + self._abort_if_unique_id_configured() + await asyncio.sleep(0.1) + return await self.async_step_link() + + async def async_step_link(self, user_input=None): + """Test a link step.""" + if user_input is None: + return self.async_show_form(step_id="link") + return self.async_create_entry(title="title", data={"token": "supersecret"}) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + # Create one to be in progress + flow1 = manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_ZEROCONF} + ) + flow2 = manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_ZEROCONF} + ) + flow3 = manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_HOMEKIT} + ) + result1, result2, result3 = await asyncio.gather(flow1, flow2, flow3) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["unique_id"] == "thisid" + + # Finish flow + result2 = await manager.flow.async_configure( + flows[0]["flow_id"], user_input={"fake": "data"} + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + assert len(hass.config_entries.flow.async_progress()) == 0 + + entry = hass.config_entries.async_entries("comp")[0] + assert entry.title == "title" + assert entry.source in { + config_entries.SOURCE_ZEROCONF, + config_entries.SOURCE_HOMEKIT, + } + assert entry.unique_id == "thisid" + + async def test_updating_entry_with_and_without_changes(manager): """Test that we can update an entry data.""" entry = MockConfigEntry( diff --git a/tests/test_core.py b/tests/test_core.py index 77ec07e6a63..641a5e0dfda 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1374,6 +1374,33 @@ async def test_additional_data_in_core_config(hass, hass_storage): assert config.location_name == "Test Name" +async def test_incorrect_internal_external_url(hass, hass_storage, caplog): + """Test that we warn when detecting invalid internal/extenral url.""" + config = ha.Config(hass) + + hass_storage[ha.CORE_STORAGE_KEY] = { + "version": 1, + "data": { + "internal_url": None, + "external_url": None, + }, + } + await config.async_load() + assert "Invalid external_url set" not in caplog.text + assert "Invalid internal_url set" not in caplog.text + + hass_storage[ha.CORE_STORAGE_KEY] = { + "version": 1, + "data": { + "internal_url": "https://community.home-assistant.io/profile", + "external_url": "https://www.home-assistant.io/blue", + }, + } + await config.async_load() + assert "Invalid external_url set" in caplog.text + assert "Invalid internal_url set" in caplog.text + + async def test_start_events(hass): """Test events fired when starting Home Assistant.""" hass.state = ha.CoreState.not_running diff --git a/tests/test_loader.py b/tests/test_loader.py index 20dcf90d90e..9786c9fdcfb 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -192,6 +192,12 @@ def test_integration_properties(hass): {"hostname": "tesla_*", "macaddress": "044EAF*"}, {"hostname": "tesla_*", "macaddress": "98ED5C*"}, ], + "usb": [ + {"vid": "10C4", "pid": "EA60"}, + {"vid": "1CF1", "pid": "0030"}, + {"vid": "1A86", "pid": "7523"}, + {"vid": "10C4", "pid": "8A2A"}, + ], "ssdp": [ { "manufacturer": "Royal Philips Electronics", @@ -216,6 +222,12 @@ def test_integration_properties(hass): {"hostname": "tesla_*", "macaddress": "044EAF*"}, {"hostname": "tesla_*", "macaddress": "98ED5C*"}, ] + assert integration.usb == [ + {"vid": "10C4", "pid": "EA60"}, + {"vid": "1CF1", "pid": "0030"}, + {"vid": "1A86", "pid": "7523"}, + {"vid": "10C4", "pid": "8A2A"}, + ] assert integration.ssdp == [ { "manufacturer": "Royal Philips Electronics", @@ -248,6 +260,7 @@ def test_integration_properties(hass): assert integration.homekit is None assert integration.zeroconf is None assert integration.dhcp is None + assert integration.usb is None assert integration.ssdp is None assert integration.mqtt is None assert integration.version is None @@ -268,6 +281,7 @@ def test_integration_properties(hass): assert integration.homekit is None assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}] assert integration.dhcp is None + assert integration.usb is None assert integration.ssdp is None @@ -342,6 +356,36 @@ def _get_test_integration_with_dhcp_matcher(hass, name, config_flow): ) +def _get_test_integration_with_usb_matcher(hass, name, config_flow): + """Return a generated test integration with a usb matcher.""" + return loader.Integration( + hass, + f"homeassistant.components.{name}", + None, + { + "name": name, + "domain": name, + "config_flow": config_flow, + "dependencies": [], + "requirements": [], + "usb": [ + { + "vid": "10C4", + "pid": "EA60", + "known_devices": ["slae.sh cc2652rb stick"], + }, + {"vid": "1CF1", "pid": "0030", "known_devices": ["Conbee II"]}, + { + "vid": "1A86", + "pid": "7523", + "known_devices": ["Electrolama zig-a-zig-ah"], + }, + {"vid": "10C4", "pid": "8A2A", "known_devices": ["Nortek HUSBZB-1"]}, + ], + }, + ) + + async def test_get_custom_components(hass, enable_custom_integrations): """Verify that custom components are cached.""" test_1_integration = _get_test_integration(hass, "test_1", False) @@ -411,6 +455,24 @@ async def test_get_dhcp(hass): ] +async def test_get_usb(hass): + """Verify that custom components with usb matchers are found.""" + test_1_integration = _get_test_integration_with_usb_matcher(hass, "test_1", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + } + usb = await loader.async_get_usb(hass) + usb_for_domain = [entry for entry in usb if entry["domain"] == "test_1"] + assert usb_for_domain == [ + {"domain": "test_1", "vid": "10C4", "pid": "EA60"}, + {"domain": "test_1", "vid": "1CF1", "pid": "0030"}, + {"domain": "test_1", "vid": "1A86", "pid": "7523"}, + {"domain": "test_1", "vid": "10C4", "pid": "8A2A"}, + ] + + async def test_get_homekit(hass): """Verify that custom components with homekit are found.""" test_1_integration = _get_test_integration(hass, "test_1", True) diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py index 88ce04bdc92..1b33b1cafbf 100644 --- a/tests/testing_config/custom_components/test/light.py +++ b/tests/testing_config/custom_components/test/light.py @@ -66,3 +66,5 @@ class MockLight(MockToggleEntity, LightEntity): "white_value", ]: setattr(self, key, value) + if key == "white": + setattr(self, "brightness", value) diff --git a/tests/testing_config/custom_components/test/number.py b/tests/testing_config/custom_components/test/number.py new file mode 100644 index 00000000000..93d7783d684 --- /dev/null +++ b/tests/testing_config/custom_components/test/number.py @@ -0,0 +1,51 @@ +""" +Provide a mock number platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.number import NumberEntity + +from tests.common import MockEntity + +UNIQUE_NUMBER = "unique_number" + +ENTITIES = [] + + +class MockNumberEntity(MockEntity, NumberEntity): + """Mock Select class.""" + + _attr_value = 50.0 + _attr_step = 1.0 + + @property + def value(self): + """Return the current value.""" + return self._handle("value") + + def set_value(self, value: float) -> None: + """Change the selected option.""" + self._attr_value = value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockNumberEntity( + name="test", + unique_id=UNIQUE_NUMBER, + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/select.py b/tests/testing_config/custom_components/test/select.py new file mode 100644 index 00000000000..375191983b5 --- /dev/null +++ b/tests/testing_config/custom_components/test/select.py @@ -0,0 +1,63 @@ +""" +Provide a mock select platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.select import SelectEntity + +from tests.common import MockEntity + +UNIQUE_SELECT_1 = "unique_select_1" +UNIQUE_SELECT_2 = "unique_select_2" + +ENTITIES = [] + + +class MockSelectEntity(MockEntity, SelectEntity): + """Mock Select class.""" + + _attr_current_option = None + + @property + def current_option(self): + """Return the current option of this select.""" + return self._handle("current_option") + + @property + def options(self) -> list: + """Return the list of available options of this select.""" + return self._handle("options") + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._attr_current_option = option + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockSelectEntity( + name="select 1", + unique_id="unique_select_1", + options=["option 1", "option 2", "option 3"], + current_option="option 1", + ), + MockSelectEntity( + name="select 2", + unique_id="unique_select_2", + options=["option 1", "option 2", "option 3"], + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 384db20d2d4..fd35d1006a0 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -5,10 +5,12 @@ Call init before using it in your tests to ensure clean test data. """ import homeassistant.components.sensor as sensor from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS, + VOLUME_CUBIC_METERS, ) from tests.common import MockEntity @@ -22,14 +24,24 @@ UNITS_OF_MEASUREMENT = { sensor.DEVICE_CLASS_CO2: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO2 concentration sensor.DEVICE_CLASS_HUMIDITY: PERCENTAGE, # % of humidity in the air sensor.DEVICE_CLASS_ILLUMINANCE: "lm", # current light level (lx/lm) + sensor.DEVICE_CLASS_NITROGEN_DIOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen dioxide + sensor.DEVICE_CLASS_NITROGEN_MONOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen monoxide + sensor.DEVICE_CLASS_NITROUS_OXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen oxide + sensor.DEVICE_CLASS_OZONE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of ozone + sensor.DEVICE_CLASS_PM1: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM1 + sensor.DEVICE_CLASS_PM10: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM10 + sensor.DEVICE_CLASS_PM25: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM2.5 sensor.DEVICE_CLASS_SIGNAL_STRENGTH: SIGNAL_STRENGTH_DECIBELS, # signal strength (dB/dBm) + sensor.DEVICE_CLASS_SULPHUR_DIOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of sulphur dioxide sensor.DEVICE_CLASS_TEMPERATURE: "C", # temperature (C/F) sensor.DEVICE_CLASS_PRESSURE: PRESSURE_HPA, # pressure (hPa/mbar) sensor.DEVICE_CLASS_POWER: "kW", # power (W/kW) sensor.DEVICE_CLASS_CURRENT: "A", # current (A) sensor.DEVICE_CLASS_ENERGY: "kWh", # energy (Wh/kWh) sensor.DEVICE_CLASS_POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) + sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs sensor.DEVICE_CLASS_VOLTAGE: "V", # voltage (V) + sensor.DEVICE_CLASS_GAS: VOLUME_CUBIC_METERS, # gas (m³) } ENTITIES = {} @@ -61,7 +73,7 @@ async def async_setup_platform( async_add_entities_callback(list(ENTITIES.values())) -class MockSensor(MockEntity): +class MockSensor(MockEntity, sensor.SensorEntity): """Mock Sensor class.""" @property @@ -70,6 +82,21 @@ class MockSensor(MockEntity): return self._handle("device_class") @property - def unit_of_measurement(self): - """Return the unit_of_measurement of this sensor.""" - return self._handle("unit_of_measurement") + def last_reset(self): + """Return the last_reset of this sensor.""" + return self._handle("last_reset") + + @property + def native_unit_of_measurement(self): + """Return the native unit_of_measurement of this sensor.""" + return self._handle("native_unit_of_measurement") + + @property + def native_value(self): + """Return the native value of this sensor.""" + return self._handle("native_value") + + @property + def state_class(self): + """Return the state class of this sensor.""" + return self._handle("state_class") diff --git a/tests/util/test_volume.py b/tests/util/test_volume.py index 2c596d92e5b..3cbf5b72130 100644 --- a/tests/util/test_volume.py +++ b/tests/util/test_volume.py @@ -3,6 +3,8 @@ import pytest from homeassistant.const import ( + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, VOLUME_GALLONS, VOLUME_LITERS, @@ -47,3 +49,21 @@ def test_convert_from_gallons(): """Test conversion from gallons to other units.""" gallons = 5 assert volume_util.convert(gallons, VOLUME_GALLONS, VOLUME_LITERS) == 18.925 + + +def test_convert_from_cubic_meters(): + """Test conversion from cubic meter to other units.""" + cubic_meters = 5 + assert ( + volume_util.convert(cubic_meters, VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET) + == 176.5733335 + ) + + +def test_convert_from_cubic_feet(): + """Test conversion from cubic feet to cubic meters to other units.""" + cubic_feets = 500 + assert ( + volume_util.convert(cubic_feets, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS) + == 14.1584233 + )