diff --git a/.coveragerc b/.coveragerc index 1293f8a71f9..f340202cdb8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,6 +8,10 @@ omit = homeassistant/scripts/*.py # omit pieces of code that rely on external devices being present + homeassistant/components/accuweather/__init__.py + homeassistant/components/accuweather/const.py + homeassistant/components/accuweather/sensor.py + homeassistant/components/accuweather/weather.py homeassistant/components/acer_projector/switch.py homeassistant/components/actiontec/device_tracker.py homeassistant/components/acmeda/__init__.py @@ -28,10 +32,6 @@ omit = homeassistant/components/agent_dvr/camera.py homeassistant/components/agent_dvr/const.py homeassistant/components/agent_dvr/helpers.py - homeassistant/components/airly/__init__.py - homeassistant/components/airly/air_quality.py - homeassistant/components/airly/sensor.py - homeassistant/components/airly/const.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/air_quality.py homeassistant/components/airvisual/sensor.py @@ -69,6 +69,9 @@ omit = homeassistant/components/avion/light.py homeassistant/components/avri/const.py homeassistant/components/avri/sensor.py + homeassistant/components/azure_devops/__init__.py + homeassistant/components/azure_devops/const.py + homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* homeassistant/components/baidu/tts.py homeassistant/components/beewi_smartclim/sensor.py @@ -139,6 +142,10 @@ omit = homeassistant/components/comfoconnect/* homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py + homeassistant/components/control4/__init__.py + homeassistant/components/control4/light.py + homeassistant/components/control4/const.py + homeassistant/components/control4/director_utils.py homeassistant/components/coolmaster/__init__.py homeassistant/components/coolmaster/climate.py homeassistant/components/coolmaster/const.py @@ -164,6 +171,8 @@ omit = homeassistant/components/devolo_home_control/binary_sensor.py homeassistant/components/devolo_home_control/const.py homeassistant/components/devolo_home_control/devolo_device.py + homeassistant/components/devolo_home_control/devolo_multi_level_switch.py + homeassistant/components/devolo_home_control/light.py homeassistant/components/devolo_home_control/sensor.py homeassistant/components/devolo_home_control/subscriber.py homeassistant/components/devolo_home_control/switch.py @@ -254,6 +263,13 @@ omit = homeassistant/components/fibaro/* homeassistant/components/filesize/sensor.py homeassistant/components/fints/sensor.py + homeassistant/components/firmata/__init__.py + homeassistant/components/firmata/binary_sensor.py + homeassistant/components/firmata/board.py + homeassistant/components/firmata/const.py + homeassistant/components/firmata/entity.py + homeassistant/components/firmata/pin.py + homeassistant/components/firmata/switch.py homeassistant/components/fitbit/sensor.py homeassistant/components/fixer/sensor.py homeassistant/components/fleetgo/device_tracker.py @@ -337,7 +353,8 @@ omit = homeassistant/components/hisense_aehw4a1/* homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/* - homeassistant/components/hlk_sw16/* + homeassistant/components/hlk_sw16/__init__.py + homeassistant/components/hlk_sw16/switch.py homeassistant/components/home_connect/* homeassistant/components/homematic/* homeassistant/components/homematic/climate.py @@ -443,8 +460,6 @@ omit = homeassistant/components/lightwave/* homeassistant/components/limitlessled/light.py homeassistant/components/linksys_smart/device_tracker.py - homeassistant/components/linky/__init__.py - homeassistant/components/linky/sensor.py homeassistant/components/linode/* homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* @@ -537,6 +552,10 @@ omit = homeassistant/components/netatmo/camera.py homeassistant/components/netatmo/climate.py homeassistant/components/netatmo/const.py + homeassistant/components/netatmo/data_handler.py + homeassistant/components/netatmo/helper.py + homeassistant/components/netatmo/light.py + homeassistant/components/netatmo/netatmo_entity_base.py homeassistant/components/netatmo/sensor.py homeassistant/components/netatmo/webhook.py homeassistant/components/netdata/sensor.py @@ -605,6 +624,9 @@ omit = homeassistant/components/orvibo/switch.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py + homeassistant/components/ovo_energy/__init__.py + homeassistant/components/ovo_energy/const.py + homeassistant/components/ovo_energy/sensor.py homeassistant/components/panasonic_bluray/media_player.py homeassistant/components/panasonic_viera/media_player.py homeassistant/components/pandora/media_player.py @@ -617,6 +639,7 @@ omit = homeassistant/components/picotts/tts.py homeassistant/components/piglow/light.py homeassistant/components/pilight/* + homeassistant/components/ping/const.py homeassistant/components/ping/binary_sensor.py homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/media_player.py @@ -679,7 +702,6 @@ omit = homeassistant/components/rest/binary_sensor.py homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py - homeassistant/components/rfxtrx/* homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py homeassistant/components/rocketchat/notify.py @@ -729,7 +751,7 @@ omit = homeassistant/components/simplisafe/lock.py homeassistant/components/simulated/sensor.py homeassistant/components/sisyphus/* - homeassistant/components/sky_hub/device_tracker.py + homeassistant/components/sky_hub/* homeassistant/components/skybeacon/sensor.py homeassistant/components/skybell/* homeassistant/components/slack/notify.py @@ -909,6 +931,7 @@ omit = homeassistant/components/vlc/media_player.py homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py + homeassistant/components/volumio/__init__.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* homeassistant/components/w800rf32/* @@ -923,6 +946,9 @@ omit = homeassistant/components/wiffi/* homeassistant/components/wink/* homeassistant/components/wirelesstag/* + homeassistant/components/wolflink/__init__.py + homeassistant/components/wolflink/sensor.py + homeassistant/components/wolflink/const.py homeassistant/components/worldtidesinfo/sensor.py homeassistant/components/worxlandroid/sensor.py homeassistant/components/x10/light.py @@ -955,7 +981,6 @@ omit = homeassistant/components/yale_smart_alarm/alarm_control_panel.py homeassistant/components/yamaha_musiccast/media_player.py homeassistant/components/yandex_transport/* - homeassistant/components/yeelight/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py homeassistant/components/zabbix/* diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6a7708c1c5c..6389a542825 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment @@ -75,7 +75,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -119,7 +119,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -163,7 +163,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -229,7 +229,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -276,7 +276,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -323,7 +323,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -367,7 +367,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -414,7 +414,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -469,7 +469,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -516,7 +516,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -548,7 +548,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -737,7 +737,7 @@ jobs: -p no:sugar \ tests - name: Upload coverage artifact - uses: actions/upload-artifact@2.1.0 + uses: actions/upload-artifact@v2.1.3 with: name: coverage-${{ matrix.python-version }}-group${{ matrix.group }} path: .coverage @@ -781,4 +781,4 @@ jobs: coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1.0.10 + uses: codecov/codecov-action@v1.0.12 diff --git a/.travis.yml b/.travis.yml index fca06468ddd..29f657d7889 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ addons: - libavfilter-dev sources: - sourceline: ppa:savoury1/ffmpeg4 + - sourceline: ppa:savoury1/multimedia python: - "3.7.1" diff --git a/CODEOWNERS b/CODEOWNERS index 59c46c916ed..0081057f086 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -14,6 +14,7 @@ homeassistant/scripts/check_config.py @kellerza # Integrations homeassistant/components/abode/* @shred86 +homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray homeassistant/components/adguard/* @frenck homeassistant/components/agent_dvr/* @ispysoftware @@ -48,6 +49,7 @@ homeassistant/components/avri/* @timvancann homeassistant/components/awair/* @ahayworth @danielsjf homeassistant/components/aws/* @awarecan homeassistant/components/axis/* @Kane610 +homeassistant/components/azure_devops/* @timmo001 homeassistant/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/azure_service_bus/* @hfurubotten homeassistant/components/beewi_smartclim/* @alemuro @@ -77,6 +79,7 @@ homeassistant/components/cloudflare/* @ludeeus homeassistant/components/comfoconnect/* @michaelarnauts homeassistant/components/config/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core +homeassistant/components/control4/* @lawtancool homeassistant/components/conversation/* @home-assistant/core homeassistant/components/coolmaster/* @OnFreund homeassistant/components/coronavirus/* @home_assistant/core @@ -110,7 +113,7 @@ homeassistant/components/edl21/* @mtdcr homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/elgato/* @frenck -homeassistant/components/elkm1/* @bdraco +homeassistant/components/elkm1/* @gwww @bdraco homeassistant/components/elv/* @majuss homeassistant/components/emby/* @mezz64 homeassistant/components/emoncms/* @borpin @@ -128,6 +131,7 @@ homeassistant/components/ezviz/* @baqs homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes +homeassistant/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff homeassistant/components/flick_electric/* @ZephireNZ homeassistant/components/flock/* @fabaff @@ -168,6 +172,7 @@ homeassistant/components/hikvisioncam/* @fbradyirl homeassistant/components/hisense_aehw4a1/* @bannhead homeassistant/components/history/* @home-assistant/core homeassistant/components/hive/* @Rendili @KJonline +homeassistant/components/hlk_sw16/* @jameshilliard homeassistant/components/home_connect/* @DavidMStraub homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @bdraco @@ -221,7 +226,6 @@ homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus homeassistant/components/life360/* @pnbruckner -homeassistant/components/linky/* @Quentame homeassistant/components/linux_battery/* @fabaff homeassistant/components/local_ip/* @issacg homeassistant/components/logger/* @home-assistant/core @@ -239,7 +243,7 @@ homeassistant/components/mediaroom/* @dgomes homeassistant/components/melcloud/* @vilppuvuorinen homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen -homeassistant/components/meteo_france/* @victorcerutti @oncleben31 @Quentame +homeassistant/components/meteo_france/* @hacf-fr @oncleben31 @Quentame homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/metoffice/* @MrHarcombe homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel @@ -297,6 +301,7 @@ homeassistant/components/openweathermap/* @fabaff homeassistant/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu +homeassistant/components/ovo_energy/* @timmo001 homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare homeassistant/components/panasonic_viera/* @joogps homeassistant/components/panel_custom/* @home-assistant/frontend @@ -362,6 +367,7 @@ homeassistant/components/signal_messenger/* @bbernhard homeassistant/components/simplisafe/* @bachya homeassistant/components/sinch/* @bendikrb homeassistant/components/sisyphus/* @jkeljo +homeassistant/components/sky_hub/* @rogerselwyn homeassistant/components/slide/* @ualex73 homeassistant/components/sma/* @kellerza homeassistant/components/smappee/* @bsmappee @@ -449,6 +455,7 @@ homeassistant/components/vilfo/* @ManneW homeassistant/components/vivotek/* @HarlemSquirrel homeassistant/components/vizio/* @raman325 homeassistant/components/vlc_telnet/* @rodripf +homeassistant/components/volumio/* @OnFreund homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff @@ -457,16 +464,17 @@ homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wiffi/* @mampfes homeassistant/components/withings/* @vangorra homeassistant/components/wled/* @frenck +homeassistant/components/wolflink/* @adamkrol93 homeassistant/components/workday/* @fabaff homeassistant/components/worldclock/* @fabaff homeassistant/components/xbox_live/* @MartinHjelmare homeassistant/components/xfinity/* @cisasteelersfan homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi -homeassistant/components/xiaomi_miio/* @rytilahti @syssi +homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yamaha_musiccast/* @jalmeroth -homeassistant/components/yandex_transport/* @rishatik92 +homeassistant/components/yandex_transport/* @rishatik92 @devbis homeassistant/components/yeelight/* @rytilahti @zewelor homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yessssms/* @flowolf diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index 41755209360..3e7821d77af 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -29,12 +29,31 @@ jobs: - template: templates/azp-job-wheels.yaml@azure parameters: builderVersion: '$(versionWheels)' - builderApk: 'build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev' + builderApk: 'build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev' builderPip: 'Cython;numpy' skipBinary: 'aiohttp' + wheelsRequirement: 'requirements.txt' + wheelsRequirementDiff: 'requirements_diff.txt' + wheelsConstraint: 'homeassistant/package_constraints.txt' + jobName: 'Wheels_Core' + preBuild: + - script: | + if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then + exit 0 + else + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements.txt + fi + displayName: 'Prepare requirements files for Home Assistant Core wheels' +- template: templates/azp-job-wheels.yaml@azure + parameters: + builderVersion: '$(versionWheels)' + builderApk: 'build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev' + builderPip: 'Cython;numpy;scikit-build' + skipBinary: 'aiohttp' wheelsRequirement: 'requirements_wheels.txt' wheelsRequirementDiff: 'requirements_diff.txt' wheelsConstraint: 'homeassistant/package_constraints.txt' + jobName: 'Wheels_Integrations' preBuild: - script: | cp requirements_all.txt requirements_wheels.txt diff --git a/build.json b/build.json index 1e5b561591d..a1db6ac2a54 100644 --- a/build.json +++ b/build.json @@ -1,11 +1,11 @@ { "image": "homeassistant/{arch}-homeassistant", "build_from": { - "aarch64": "homeassistant/aarch64-homeassistant-base:8.0.0", - "armhf": "homeassistant/armhf-homeassistant-base:8.0.0", - "armv7": "homeassistant/armv7-homeassistant-base:8.0.0", - "amd64": "homeassistant/amd64-homeassistant-base:8.0.0", - "i386": "homeassistant/i386-homeassistant-base:8.0.0" + "aarch64": "homeassistant/aarch64-homeassistant-base:8.2.1", + "armhf": "homeassistant/armhf-homeassistant-base:8.2.1", + "armv7": "homeassistant/armv7-homeassistant-base:8.2.1", + "amd64": "homeassistant/amd64-homeassistant-base:8.2.1", + "i386": "homeassistant/i386-homeassistant-base:8.2.1" }, "labels": { "io.hass.type": "core" diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index cd33a4207a8..ec56b746706 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -7,7 +7,7 @@ from homeassistant.util.async_ import protect_loop def enable() -> None: """Enable the detection of I/O in the event loop.""" # Prevent urllib3 and requests doing I/O in event loop - HTTPConnection.putrequest = protect_loop(HTTPConnection.putrequest) + HTTPConnection.putrequest = protect_loop(HTTPConnection.putrequest) # type: ignore # Currently disabled. pytz doing I/O when getting timezone. # Prevent files being opened inside the event loop diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7d20ca0ce90..a7953cbec6c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -6,10 +6,10 @@ import logging import logging.handlers import os import sys +import threading from time import monotonic from typing import TYPE_CHECKING, Any, Dict, Optional, Set -from async_timeout import timeout import voluptuous as vol import yarl @@ -44,6 +44,11 @@ DATA_LOGGING = "logging" LOG_SLOW_STARTUP_INTERVAL = 60 +STAGE_1_TIMEOUT = 120 +STAGE_2_TIMEOUT = 300 +WRAP_UP_TIMEOUT = 300 +COOLDOWN_TIME = 60 + DEBUGGER_INTEGRATIONS = {"debugpy", "ptvsd"} CORE_INTEGRATIONS = ("homeassistant", "persistent_notification") LOGGING_INTEGRATIONS = { @@ -136,7 +141,7 @@ async def async_setup_hass( hass.async_track_tasks() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP, {}) with contextlib.suppress(asyncio.TimeoutError): - async with timeout(10): + async with hass.timeout.async_timeout(10): await hass.async_block_till_done() safe_mode = True @@ -304,6 +309,12 @@ def async_enable_logging( "Uncaught exception", exc_info=args # type: ignore ) + if sys.version_info[:2] >= (3, 8): + threading.excepthook = lambda args: logging.getLogger(None).exception( + "Uncaught thread exception", + exc_info=(args.exc_type, args.exc_value, args.exc_traceback), + ) + # Log errors to a file if we have write access to file or config dir if log_file is None: err_log_path = hass.config.path(ERROR_LOG_FILENAME) @@ -496,24 +507,42 @@ async def _async_set_up_integrations( stage_2_domains = domains_to_setup - logging_domains - debuggers - stage_1_domains # Kick off loading the registries. They don't need to be awaited. - asyncio.gather( - hass.helpers.device_registry.async_get_registry(), - hass.helpers.entity_registry.async_get_registry(), - hass.helpers.area_registry.async_get_registry(), - ) + asyncio.create_task(hass.helpers.device_registry.async_get_registry()) + asyncio.create_task(hass.helpers.entity_registry.async_get_registry()) + asyncio.create_task(hass.helpers.area_registry.async_get_registry()) # Start setup if stage_1_domains: _LOGGER.info("Setting up stage 1: %s", stage_1_domains) - await async_setup_multi_components(hass, stage_1_domains, config, setup_started) + try: + async with hass.timeout.async_timeout( + STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME + ): + await async_setup_multi_components( + hass, stage_1_domains, config, setup_started + ) + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for stage 1 - moving forward") # Enables after dependencies async_set_domains_to_be_loaded(hass, stage_1_domains | stage_2_domains) if stage_2_domains: _LOGGER.info("Setting up stage 2: %s", stage_2_domains) - await async_setup_multi_components(hass, stage_2_domains, config, setup_started) + try: + async with hass.timeout.async_timeout( + STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME + ): + await async_setup_multi_components( + hass, stage_2_domains, config, setup_started + ) + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for stage 2 - moving forward") # Wrap up startup _LOGGER.debug("Waiting for startup to wrap up") - await hass.async_block_till_done() + try: + async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): + await hass.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for bootstrap - moving forward") diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py new file mode 100644 index 00000000000..1e1a434a036 --- /dev/null +++ b/homeassistant/components/accuweather/__init__.py @@ -0,0 +1,132 @@ +"""The AccuWeather component.""" +import asyncio +from datetime import timedelta +import logging + +from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_FORECAST, + CONF_FORECAST, + COORDINATOR, + DOMAIN, + UNDO_UPDATE_LISTENER, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor", "weather"] + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured AccuWeather.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass, config_entry) -> bool: + """Set up AccuWeather as config entry.""" + api_key = config_entry.data[CONF_API_KEY] + location_key = config_entry.unique_id + forecast = config_entry.options.get(CONF_FORECAST, False) + + _LOGGER.debug("Using location_key: %s, get forecast: %s", location_key, forecast) + + websession = async_get_clientsession(hass) + + coordinator = AccuWeatherDataUpdateCoordinator( + hass, websession, api_key, location_key, forecast + ) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + undo_listener = config_entry.add_update_listener(update_listener) + + hass.data[DOMAIN][config_entry.entry_id] = { + COORDINATOR: coordinator, + UNDO_UPDATE_LISTENER: undo_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def update_listener(hass, config_entry): + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching AccuWeather data API.""" + + def __init__(self, hass, session, api_key, location_key, forecast: bool): + """Initialize.""" + self.location_key = location_key + self.forecast = forecast + self.is_metric = hass.config.units.is_metric + self.accuweather = AccuWeather(api_key, session, location_key=self.location_key) + + # Enabling the forecast download increases the number of requests per data + # update, we use 32 minutes for current condition only and 64 minutes for + # current condition and forecast as update interval to not exceed allowed number + # of requests. We have 50 requests allowed per day, so we use 45 and leave 5 as + # a reserve for restarting HA. + update_interval = ( + timedelta(minutes=64) if self.forecast else timedelta(minutes=32) + ) + _LOGGER.debug("Data will be update every %s", update_interval) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + """Update data via library.""" + try: + async with timeout(10): + current = await self.accuweather.async_get_current_conditions() + forecast = ( + await self.accuweather.async_get_forecast(metric=self.is_metric) + if self.forecast + else {} + ) + except ( + ApiError, + ClientConnectorError, + InvalidApiKeyError, + RequestsExceededError, + ) as error: + raise UpdateFailed(error) + _LOGGER.debug("Requests remaining: %s", self.accuweather.requests_remaining) + return {**current, **{ATTR_FORECAST: forecast}} diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py new file mode 100644 index 00000000000..d50a2ac406b --- /dev/null +++ b/homeassistant/components/accuweather/config_flow.py @@ -0,0 +1,112 @@ +"""Adds config flow for AccuWeather.""" +import asyncio + +from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_FORECAST, DOMAIN # pylint:disable=unused-import + + +class AccuWeatherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for AccuWeather.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + # Under the terms of use of the API, one user can use one free API key. Due to + # the small number of requests allowed, we only allow one integration instance. + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + websession = async_get_clientsession(self.hass) + try: + with timeout(10): + accuweather = AccuWeather( + user_input[CONF_API_KEY], + websession, + latitude=user_input[CONF_LATITUDE], + longitude=user_input[CONF_LONGITUDE], + ) + await accuweather.async_get_location() + except (ApiError, ClientConnectorError, asyncio.TimeoutError, ClientError): + errors["base"] = "cannot_connect" + except InvalidApiKeyError: + errors[CONF_API_KEY] = "invalid_api_key" + except RequestsExceededError: + errors[CONF_API_KEY] = "requests_exceeded" + else: + await self.async_set_unique_id( + accuweather.location_key, raise_on_progress=False + ) + + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + vol.Optional( + CONF_NAME, default=self.hass.config.location_name + ): str, + } + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Options callback for AccuWeather.""" + return AccuWeatherOptionsFlowHandler(config_entry) + + +class AccuWeatherOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options for AccuWeather.""" + + def __init__(self, config_entry): + """Initialize AccuWeather options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_FORECAST, + default=self.config_entry.options.get(CONF_FORECAST, False), + ): bool + } + ), + ) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py new file mode 100644 index 00000000000..b189a776750 --- /dev/null +++ b/homeassistant/components/accuweather/const.py @@ -0,0 +1,279 @@ +"""Constants for AccuWeather integration.""" +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + DEVICE_CLASS_TEMPERATURE, + LENGTH_FEET, + LENGTH_INCHES, + LENGTH_METERS, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TIME_HOURS, + UNIT_PERCENTAGE, + UV_INDEX, + VOLUME_CUBIC_METERS, +) + +ATTRIBUTION = "Data provided by AccuWeather" +ATTR_ICON = "icon" +ATTR_FORECAST = CONF_FORECAST = "forecast" +ATTR_LABEL = "label" +ATTR_UNIT_IMPERIAL = "Imperial" +ATTR_UNIT_METRIC = "Metric" +CONCENTRATION_PARTS_PER_CUBIC_METER = f"p/{VOLUME_CUBIC_METERS}" +COORDINATOR = "coordinator" +DOMAIN = "accuweather" +LENGTH_MILIMETERS = "mm" +MANUFACTURER = "AccuWeather, Inc." +NAME = "AccuWeather" +UNDO_UPDATE_LISTENER = "undo_update_listener" + +CONDITION_CLASSES = { + "clear-night": [33, 34, 37], + "cloudy": [7, 8, 38], + "exceptional": [24, 30, 31], + "fog": [11], + "hail": [25], + "lightning": [15], + "lightning-rainy": [16, 17, 41, 42], + "partlycloudy": [4, 6, 35, 36], + "pouring": [18], + "rainy": [12, 13, 14, 26, 39, 40], + "snowy": [19, 20, 21, 22, 23, 43, 44], + "snowy-rainy": [29], + "sunny": [1, 2, 3, 5], + "windy": [32], +} + +FORECAST_DAYS = [0, 1, 2, 3, 4] + +FORECAST_SENSOR_TYPES = { + "CloudCoverDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover Day", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "CloudCoverNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover Night", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "Grass": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:grass", + ATTR_LABEL: "Grass Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "HoursOfSun": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-partly-cloudy", + ATTR_LABEL: "Hours Of Sun", + ATTR_UNIT_METRIC: TIME_HOURS, + ATTR_UNIT_IMPERIAL: TIME_HOURS, + }, + "Mold": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:blur", + ATTR_LABEL: "Mold Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "Ozone": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:vector-triangle", + ATTR_LABEL: "Ozone", + ATTR_UNIT_METRIC: None, + ATTR_UNIT_IMPERIAL: None, + }, + "Ragweed": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:sprout", + ATTR_LABEL: "Ragweed Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "RealFeelTemperatureMax": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Max", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureMin": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Min", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShadeMax": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade Max", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShadeMin": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade Min", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "ThunderstormProbabilityDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-lightning", + ATTR_LABEL: "Thunderstorm Probability Day", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "ThunderstormProbabilityNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-lightning", + ATTR_LABEL: "Thunderstorm Probability Night", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "Tree": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:tree-outline", + ATTR_LABEL: "Tree Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "UVIndex": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-sunny", + ATTR_LABEL: "UV Index", + ATTR_UNIT_METRIC: UV_INDEX, + ATTR_UNIT_IMPERIAL: UV_INDEX, + }, + "WindGustDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust Day", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, + "WindGustNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust Night", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, +} + +OPTIONAL_SENSORS = ( + "ApparentTemperature", + "CloudCover", + "CloudCoverDay", + "CloudCoverNight", + "DewPoint", + "Grass", + "Mold", + "Ozone", + "Ragweed", + "RealFeelTemperatureShade", + "RealFeelTemperatureShadeMax", + "RealFeelTemperatureShadeMin", + "Tree", + "WetBulbTemperature", + "WindChillTemperature", + "WindGust", + "WindGustDay", + "WindGustNight", +) + +SENSOR_TYPES = { + "ApparentTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Apparent Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "Ceiling": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-fog", + ATTR_LABEL: "Cloud Ceiling", + ATTR_UNIT_METRIC: LENGTH_METERS, + ATTR_UNIT_IMPERIAL: LENGTH_FEET, + }, + "CloudCover": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "DewPoint": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Dew Point", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShade": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "Precipitation": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-rainy", + ATTR_LABEL: "Precipitation", + ATTR_UNIT_METRIC: LENGTH_MILIMETERS, + ATTR_UNIT_IMPERIAL: LENGTH_INCHES, + }, + "PressureTendency": { + ATTR_DEVICE_CLASS: "accuweather__pressure_tendency", + ATTR_ICON: "mdi:gauge", + ATTR_LABEL: "Pressure Tendency", + ATTR_UNIT_METRIC: None, + ATTR_UNIT_IMPERIAL: None, + }, + "UVIndex": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-sunny", + ATTR_LABEL: "UV Index", + ATTR_UNIT_METRIC: UV_INDEX, + ATTR_UNIT_IMPERIAL: UV_INDEX, + }, + "WetBulbTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Wet Bulb Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "WindChillTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Wind Chill Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "WindGust": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, +} diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json new file mode 100644 index 00000000000..4e54d937dee --- /dev/null +++ b/homeassistant/components/accuweather/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "accuweather", + "name": "AccuWeather", + "documentation": "https://www.home-assistant.io/integrations/accuweather/", + "requirements": ["accuweather==0.0.9"], + "codeowners": ["@bieniu"], + "config_flow": true +} diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py new file mode 100644 index 00000000000..878f387c35c --- /dev/null +++ b/homeassistant/components/accuweather/sensor.py @@ -0,0 +1,189 @@ +"""Support for the AccuWeather service.""" +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + CONF_NAME, + DEVICE_CLASS_TEMPERATURE, +) +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_FORECAST, + ATTR_ICON, + ATTR_LABEL, + ATTRIBUTION, + COORDINATOR, + DOMAIN, + FORECAST_DAYS, + FORECAST_SENSOR_TYPES, + MANUFACTURER, + NAME, + OPTIONAL_SENSORS, + SENSOR_TYPES, +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add AccuWeather entities from a config_entry.""" + name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + + sensors = [] + for sensor in SENSOR_TYPES: + sensors.append(AccuWeatherSensor(name, sensor, coordinator)) + + if coordinator.forecast: + for sensor in FORECAST_SENSOR_TYPES: + for day in FORECAST_DAYS: + # Some air quality/allergy sensors are only available for certain + # locations. + if sensor in coordinator.data[ATTR_FORECAST][0]: + sensors.append( + AccuWeatherSensor(name, sensor, coordinator, forecast_day=day) + ) + + async_add_entities(sensors, False) + + +class AccuWeatherSensor(Entity): + """Define an AccuWeather entity.""" + + def __init__(self, name, kind, coordinator, forecast_day=None): + """Initialize.""" + self._name = name + self.kind = kind + self.coordinator = coordinator + self._device_class = None + self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._unit_system = "Metric" if self.coordinator.is_metric else "Imperial" + self.forecast_day = forecast_day + + @property + def name(self): + """Return the name.""" + if self.forecast_day is not None: + return f"{self._name} {FORECAST_SENSOR_TYPES[self.kind][ATTR_LABEL]} {self.forecast_day}d" + return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + if self.forecast_day is not None: + return f"{self.coordinator.location_key}-{self.kind}-{self.forecast_day}".lower() + return f"{self.coordinator.location_key}-{self.kind}".lower() + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self.coordinator.location_key)}, + "name": NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def state(self): + """Return the state.""" + if self.forecast_day is not None: + if ( + FORECAST_SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + == DEVICE_CLASS_TEMPERATURE + ): + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Value"] + if self.kind in ["WindGustDay", "WindGustNight"]: + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Speed"]["Value"] + if self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Value"] + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][self.kind] + if self.kind == "Ceiling": + return round(self.coordinator.data[self.kind][self._unit_system]["Value"]) + if self.kind == "PressureTendency": + return self.coordinator.data[self.kind]["LocalizedText"].lower() + if SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE: + return self.coordinator.data[self.kind][self._unit_system]["Value"] + if self.kind == "Precipitation": + return self.coordinator.data["PrecipitationSummary"][self.kind][ + self._unit_system + ]["Value"] + if self.kind == "WindGust": + return self.coordinator.data[self.kind]["Speed"][self._unit_system]["Value"] + return self.coordinator.data[self.kind] + + @property + def icon(self): + """Return the icon.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][ATTR_ICON] + return SENSOR_TYPES[self.kind][ATTR_ICON] + + @property + def device_class(self): + """Return the device_class.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][self._unit_system] + return SENSOR_TYPES[self.kind][self._unit_system] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.forecast_day is not None: + if self.kind == "WindGustDay": + self._attrs["direction"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Direction"]["English"] + elif self.kind == "WindGustNight": + self._attrs["direction"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Direction"]["English"] + elif self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: + self._attrs["level"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Category"] + return self._attrs + if self.kind == "UVIndex": + self._attrs["level"] = self.coordinator.data["UVIndexText"] + elif self.kind == "Precipitation": + self._attrs["type"] = self.coordinator.data["PrecipitationType"] + return self._attrs + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return bool(self.kind not in OPTIONAL_SENSORS) + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update AccuWeather entity.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json new file mode 100644 index 00000000000..89228cd0692 --- /dev/null +++ b/homeassistant/components/accuweather/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "title": "AccuWeather", + "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nSome sensors are not enabled by default. You can enable them in the entity registry after the integration configuration.\nWeather forecast is not enabled by default. You can enable it in the integration options.", + "data": { + "name": "Name of the integration", + "api_key": "[%key:common::config_flow::data::api_key%]", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "requests_exceeded": "The allowed number of requests to Accuweather API has been exceeded. You have to wait or change API Key." + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options": { + "step": { + "user": { + "title": "AccuWeather Options", + "description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 64 minutes instead of every 32 minutes.", + "data": { + "forecast": "Weather forecast" + } + } + } + } +} diff --git a/homeassistant/components/accuweather/strings.sensor.json b/homeassistant/components/accuweather/strings.sensor.json new file mode 100644 index 00000000000..57cb89bcecf --- /dev/null +++ b/homeassistant/components/accuweather/strings.sensor.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "steady": "Steady", + "rising": "Rising", + "falling": "Falling" + } + } +} diff --git a/homeassistant/components/accuweather/translations/ca.json b/homeassistant/components/accuweather/translations/ca.json new file mode 100644 index 00000000000..72eea7f65cc --- /dev/null +++ b/homeassistant/components/accuweather/translations/ca.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_api_key": "Clau API inv\u00e0lida", + "requests_exceeded": "S'ha superat el nombre m\u00e0xim de sol\u00b7licituds permeses a l'API d'AccuWeather. Has d'esperar-te o canviar la clau API." + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom de la integraci\u00f3" + }, + "description": "Si necessites ajuda amb la configuraci\u00f3, consulta: https://www.home-assistant.io/integrations/accuweather/ \n\n La previsi\u00f3 meteorol\u00f2gica no est\u00e0 habilitada de manera predeterminada. Pots activar-la en les opcions de la integraci\u00f3.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Previsi\u00f3 meteorol\u00f2gica" + }, + "description": "Per culpa de les limitacions de la versi\u00f3 gratu\u00efta l'API d'AccuWeather, quan habilitis la previsi\u00f3 meteorol\u00f2gica, les actualitzacions es realitzaran cada 64 minuts en comptes de 32.", + "title": "Opcions d'AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/en.json b/homeassistant/components/accuweather/translations/en.json new file mode 100644 index 00000000000..8382236e7a0 --- /dev/null +++ b/homeassistant/components/accuweather/translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key", + "requests_exceeded": "The allowed number of requests to Accuweather API has been exceeded. You have to wait or change API Key." + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name of the integration" + }, + "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nSome sensors are not enabled by default. You can enable them in the entity registry after the integration configuration.\nWeather forecast is not enabled by default. You can enable it in the integration options.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Weather forecast" + }, + "description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 64 minutes instead of every 32 minutes.", + "title": "AccuWeather Options" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/es.json b/homeassistant/components/accuweather/translations/es.json new file mode 100644 index 00000000000..4de57575464 --- /dev/null +++ b/homeassistant/components/accuweather/translations/es.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave API no v\u00e1lida", + "requests_exceeded": "Se ha excedido el n\u00famero permitido de solicitudes a la API de Accuweather. Tienes que esperar o cambiar la Clave API." + }, + "step": { + "user": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre de la integraci\u00f3n" + }, + "description": "Si necesitas ayuda con la configuraci\u00f3n, echa un vistazo aqu\u00ed: https://www.home-assistant.io/integrations/accuweather/ \n\nEl pron\u00f3stico del tiempo no est\u00e1 habilitado por defecto. Puedes habilitarlo en las opciones de la integraci\u00f3n.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Pron\u00f3stico del tiempo" + }, + "description": "Debido a las limitaciones de la versi\u00f3n gratuita de la clave API de AccuWeather, cuando habilitas el pron\u00f3stico del tiempo, las actualizaciones de datos se realizar\u00e1n cada 64 minutos en lugar de cada 32 minutos.", + "title": "Opciones de AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/it.json b/homeassistant/components/accuweather/translations/it.json new file mode 100644 index 00000000000..398f7e1e663 --- /dev/null +++ b/homeassistant/components/accuweather/translations/it.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_api_key": "Chiave API non valida", + "requests_exceeded": "\u00c8 stato superato il numero consentito di richieste all'API di Accuweather. \u00c8 necessario attendere o modificare la chiave API." + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API", + "latitude": "Latitudine", + "longitude": "Logitudine", + "name": "Nome dell'integrazione" + }, + "description": "Se hai bisogno di aiuto con la configurazione dai un'occhiata qui: https://www.home-assistant.io/integrations/accuweather/ \n\nAlcuni sensori non sono abilitati per impostazione predefinita. \u00c8 possibile abilitarli nel registro entit\u00e0 dopo la configurazione di integrazione. \nLe previsioni meteo non sono abilitate per impostazione predefinita. Puoi abilitarle nelle opzioni di integrazione.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Previsioni meteo" + }, + "description": "A causa delle limitazioni della versione gratuita della chiave API AccuWeather, quando si abilitano le previsioni del tempo, gli aggiornamenti dei dati verranno eseguiti ogni 64 minuti invece che ogni 32 minuti.", + "title": "Opzioni AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/lb.json b/homeassistant/components/accuweather/translations/lb.json new file mode 100644 index 00000000000..b83cf69c8d7 --- /dev/null +++ b/homeassistant/components/accuweather/translations/lb.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun ass m\u00e9iglech." + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel", + "requests_exceeded": "D\u00e9i zougelooss Zuel vun Ufroen un Accuweather API gouf iwwerschratt. Du muss ofwaarden oder den API Schl\u00ebssel \u00e4nneren." + }, + "step": { + "user": { + "data": { + "api_key": "API Schl\u00ebssel", + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad", + "name": "Numm vun der Integratioun" + }, + "description": "Falls du H\u00ebllef mat der Konfiguratioun brauch kuck h\u00e9i:\nhttps://www.home-assistant.io/integrations/accuweather/\n\nWieder Pr\u00e9visounen si standardm\u00e9isseg net aktiv. Du kanns d\u00e9i an den Optioune vun der Integratioun aschalten.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Wieder Pr\u00e9visioun" + }, + "description": "Duerch d'Limite vun der Gratis Versioun vun der AccuWeather API, wann d'Wieder Pr\u00e9visoune aktiv\u00e9iert sinn, ginn d'Aktualis\u00e9ierungen all 64 Minutten gemaach, am plaatz vun all 32 Minutten.", + "title": "AccuWeather Optiounen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/no.json b/homeassistant/components/accuweather/translations/no.json new file mode 100644 index 00000000000..c6cbc82bc2c --- /dev/null +++ b/homeassistant/components/accuweather/translations/no.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_api_key": "Ugyldig API-n\u00f8kkel", + "requests_exceeded": "Det tillatte antallet foresp\u00f8rsler til Accuweather API er overskredet. Du m\u00e5 vente eller endre API-n\u00f8kkel." + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "name": "Navn p\u00e5 integrasjon" + }, + "description": "Hvis du trenger hjelp med konfigurasjonen, kan du se her: https://www.home-assistant.io/integrations/accuweather/ \n\n Noen sensorer er ikke aktivert som standard. Du kan aktivere dem i enhetsregisteret etter integrasjonskonfigurasjonen. \n V\u00e6rmelding er ikke aktivert som standard. Du kan aktivere det i integrasjonsalternativene.", + "title": "" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "V\u00e6rmelding" + }, + "description": "P\u00e5 grunn av begrensningene i gratisversjonen av AccuWeather API-n\u00f8kkelen, n\u00e5r du aktiverer v\u00e6rmelding, vil dataoppdateringer bli utf\u00f8rt hvert 64. minutt i stedet for hvert 32. minutt.", + "title": "AccuWeather-alternativer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/pl.json b/homeassistant/components/accuweather/translations/pl.json new file mode 100644 index 00000000000..273458db00c --- /dev/null +++ b/homeassistant/components/accuweather/translations/pl.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_api_key": "Nieprawid\u0142owy klucz API.", + "requests_exceeded": "Dozwolona liczba zapyta\u0144 do interfejsu API Accuweather zosta\u0142a przekroczona. Musisz poczeka\u0107 lub zmieni\u0107 klucz API." + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "name": "Nazwa integracji" + }, + "description": "Je\u015bli potrzebujesz pomocy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/accuweather/ \n\nCz\u0119\u015b\u0107 sensor\u00f3w nie jest w\u0142\u0105czona domy\u015blnie. Mo\u017cesz je w\u0142\u0105czy\u0107 w rejestrze encji po konfiguracji integracji.\nPrognoza pogody nie jest domy\u015blnie w\u0142\u0105czona. Mo\u017cesz j\u0105 w\u0142\u0105czy\u0107 w opcjach integracji.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Prognoza pogody" + }, + "description": "Ze wzgl\u0119du na ograniczenia darmowej wersji klucza API AccuWeather po w\u0142\u0105czeniu prognozy pogody aktualizacje danych b\u0119d\u0105 wykonywane co 64 minut zamiast co 32 minut.", + "title": "Opcje AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/pt.json b/homeassistant/components/accuweather/translations/pt.json new file mode 100644 index 00000000000..6288344fd6b --- /dev/null +++ b/homeassistant/components/accuweather/translations/pt.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Previs\u00e3o meteorol\u00f3gica" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/ru.json b/homeassistant/components/accuweather/translations/ru.json new file mode 100644 index 00000000000..8803659ccbb --- /dev/null +++ b/homeassistant/components/accuweather/translations/ru.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "requests_exceeded": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d\u043e \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u043a API Accuweather. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u043e\u0434\u043e\u0436\u0434\u0430\u0442\u044c \u0438\u043b\u0438 \u0438\u0437\u043c\u0435\u043d\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438, \u0435\u0441\u043b\u0438 \u0412\u0430\u043c \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u043c\u043e\u0449\u044c \u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439:\nhttps://www.home-assistant.io/integrations/accuweather/ \n\n\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u043d\u0435\u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u044b \u0441\u043a\u0440\u044b\u0442\u044b \u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d \u043f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u044b. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u043d\u0443\u0436\u043d\u044b\u0445 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0432 \u0440\u0435\u0435\u0441\u0442\u0440\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0438 \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u044b \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u041f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u044b" + }, + "description": "\u0412 \u0441\u0432\u044f\u0437\u0438 \u0441 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u043c\u0438 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u043a\u043b\u044e\u0447\u0430 API AccuWeather, \u043f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 \u043f\u043e\u0433\u043e\u0434\u044b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u0434\u0435\u0442 \u043f\u0440\u043e\u0438\u0441\u0445\u043e\u0434\u0438\u0442\u044c \u043a\u0430\u0436\u0434\u044b\u0435 64 \u043c\u0438\u043d\u0443\u0442\u044b, \u0430 \u043d\u0435 \u043a\u0430\u0436\u0434\u044b\u0435 32 \u043c\u0438\u043d\u0443\u0442\u044b.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.cs.json b/homeassistant/components/accuweather/translations/sensor.cs.json new file mode 100644 index 00000000000..e49b09927d5 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.cs.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Klesaj\u00edc\u00ed", + "rising": "Roustouc\u00ed", + "steady": "St\u00e1l\u00fd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.en.json b/homeassistant/components/accuweather/translations/sensor.en.json new file mode 100644 index 00000000000..8786583686b --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.en.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Falling", + "rising": "Rising", + "steady": "Steady" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.es.json b/homeassistant/components/accuweather/translations/sensor.es.json new file mode 100644 index 00000000000..72d666b1ba3 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.es.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Cayendo", + "rising": "Subiendo", + "steady": "Estable" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.it.json b/homeassistant/components/accuweather/translations/sensor.it.json new file mode 100644 index 00000000000..9252821b8de --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.it.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Diminuzione", + "rising": "Aumento", + "steady": "Stabile" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.no.json b/homeassistant/components/accuweather/translations/sensor.no.json new file mode 100644 index 00000000000..abe8a935115 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.no.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Fallende", + "rising": "Stiger", + "steady": "Jevn" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.pl.json b/homeassistant/components/accuweather/translations/sensor.pl.json new file mode 100644 index 00000000000..cc7ba9b873c --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "spada", + "rising": "ro\u015bnie", + "steady": "bez zmian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.ru.json b/homeassistant/components/accuweather/translations/sensor.ru.json new file mode 100644 index 00000000000..fd791040d9f --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u041f\u043e\u043d\u0438\u0436\u0430\u044e\u0449\u0435\u0435\u0441\u044f", + "rising": "\u041f\u043e\u0432\u044b\u0448\u0430\u044e\u0449\u0435\u0435\u0441\u044f", + "steady": "\u041f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/uk.json b/homeassistant/components/accuweather/translations/uk.json new file mode 100644 index 00000000000..8c3f282b350 --- /dev/null +++ b/homeassistant/components/accuweather/translations/uk.json @@ -0,0 +1,26 @@ +{ + "config": { + "error": { + "invalid_api_key": "\u0425\u0438\u0431\u043d\u0438\u0439 \u043a\u043b\u044e\u0447 API" + }, + "step": { + "user": { + "data": { + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u0432\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430 \u0456\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0456\u0457" + }, + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u041f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u0438" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/zh-Hant.json b/homeassistant/components/accuweather/translations/zh-Hant.json new file mode 100644 index 00000000000..d5f3acfc81c --- /dev/null +++ b/homeassistant/components/accuweather/translations/zh-Hant.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u8a2d\u5099\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "requests_exceeded": "\u5df2\u8d85\u904e Accuweather API \u5141\u8a31\u7684\u8acb\u6c42\u6b21\u6578\u3002\u5fc5\u9808\u7b49\u5019\u6216\u8b8a\u66f4 API \u5bc6\u9470\u3002" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "name": "\u6574\u5408\u540d\u7a31" + }, + "description": "\u5047\u5982\u4f60\u9700\u8981\u5354\u52a9\u9032\u884c\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1\uff1ahttps://www.home-assistant.io/integrations/accuweather/\n\n\u5929\u6c23\u9810\u5831\u9810\u8a2d\u672a\u958b\u555f\u3002\u53ef\u4ee5\u65bc\u6574\u5408\u9078\u9805\u4e2d\u958b\u555f\u3002", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u5929\u6c23\u9810\u5831" + }, + "description": "\u7531\u65bc AccuWeather API \u5bc6\u9470\u514d\u8cbb\u7248\u672c\u9650\u5236\uff0c\u7576\u958b\u555f\u5929\u6c23\u9810\u5831\u6642\u3001\u6578\u64da\u6703\u6bcf 64 \u5206\u9418\u66f4\u65b0\u4e00\u6b21\uff0c\u800c\u975e 32 \u5206\u9418\u3002", + "title": "AccuWeather \u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py new file mode 100644 index 00000000000..47d1fef7b14 --- /dev/null +++ b/homeassistant/components/accuweather/weather.py @@ -0,0 +1,195 @@ +"""Support for the AccuWeather service.""" +from statistics import mean + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + WeatherEntity, +) +from homeassistant.const import CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.util.dt import utc_from_timestamp + +from .const import ( + ATTR_FORECAST, + ATTRIBUTION, + CONDITION_CLASSES, + COORDINATOR, + DOMAIN, + MANUFACTURER, + NAME, +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a AccuWeather weather entity from a config_entry.""" + name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + + async_add_entities([AccuWeatherEntity(name, coordinator)], False) + + +class AccuWeatherEntity(WeatherEntity): + """Define an AccuWeather entity.""" + + def __init__(self, name, coordinator): + """Initialize.""" + self._name = name + self.coordinator = coordinator + self._attrs = {} + self._unit_system = "Metric" if self.coordinator.is_metric else "Imperial" + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self.coordinator.location_key + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self.coordinator.location_key)}, + "name": NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def condition(self): + """Return the current condition.""" + try: + return [ + k + for k, v in CONDITION_CLASSES.items() + if self.coordinator.data["WeatherIcon"] in v + ][0] + except IndexError: + return None + + @property + def temperature(self): + """Return the temperature.""" + return self.coordinator.data["Temperature"][self._unit_system]["Value"] + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS if self.coordinator.is_metric else TEMP_FAHRENHEIT + + @property + def pressure(self): + """Return the pressure.""" + return self.coordinator.data["Pressure"][self._unit_system]["Value"] + + @property + def humidity(self): + """Return the humidity.""" + return self.coordinator.data["RelativeHumidity"] + + @property + def wind_speed(self): + """Return the wind speed.""" + return self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self.coordinator.data["Wind"]["Direction"]["Degrees"] + + @property + def visibility(self): + """Return the visibility.""" + return self.coordinator.data["Visibility"][self._unit_system]["Value"] + + @property + def ozone(self): + """Return the ozone level.""" + # We only have ozone data for certain locations and only in the forecast data. + if self.coordinator.forecast and self.coordinator.data[ATTR_FORECAST][0].get( + "Ozone" + ): + return self.coordinator.data[ATTR_FORECAST][0]["Ozone"]["Value"] + return None + + @property + def forecast(self): + """Return the forecast array.""" + if not self.coordinator.forecast: + return None + # remap keys from library to keys understood by the weather component + forecast = [ + { + ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), + ATTR_FORECAST_TEMP: item["TemperatureMax"]["Value"], + ATTR_FORECAST_TEMP_LOW: item["TemperatureMin"]["Value"], + ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(item), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: round( + mean( + [ + item["PrecipitationProbabilityDay"], + item["PrecipitationProbabilityNight"], + ] + ) + ), + ATTR_FORECAST_WIND_SPEED: item["WindDay"]["Speed"]["Value"], + ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], + ATTR_FORECAST_CONDITION: [ + k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v + ][0], + } + for item in self.coordinator.data[ATTR_FORECAST] + ] + return forecast + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update AccuWeather entity.""" + await self.coordinator.async_request_refresh() + + @staticmethod + def _calc_precipitation(day: dict) -> float: + """Return sum of the precipitation.""" + precip_sum = 0 + precip_types = ["Rain", "Snow", "Ice"] + for precip in precip_types: + precip_sum = sum( + [ + precip_sum, + day[f"{precip}Day"]["Value"], + day[f"{precip}Night"]["Value"], + ] + ) + return round(precip_sum, 1) diff --git a/homeassistant/components/adguard/translations/no.json b/homeassistant/components/adguard/translations/no.json index 0633e817db9..bcd6fa5361d 100644 --- a/homeassistant/components/adguard/translations/no.json +++ b/homeassistant/components/adguard/translations/no.json @@ -17,8 +17,10 @@ "user": { "data": { "host": "Vert", + "password": "Passord", "port": "", "ssl": "AdGuard Hjem bruker et SSL-sertifikat", + "username": "Brukernavn", "verify_ssl": "AdGuard Home bruker et riktig sertifikat" }, "description": "Sett opp din AdGuard Hjem instans for \u00e5 tillate overv\u00e5king og kontroll." diff --git a/homeassistant/components/linky/translations/lv.json b/homeassistant/components/agent_dvr/translations/nl.json similarity index 76% rename from homeassistant/components/linky/translations/lv.json rename to homeassistant/components/agent_dvr/translations/nl.json index 973833a5470..c1909b19508 100644 --- a/homeassistant/components/linky/translations/lv.json +++ b/homeassistant/components/agent_dvr/translations/nl.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "username": "E-pasts" + "port": "Poort" } } } diff --git a/homeassistant/components/agent_dvr/translations/no.json b/homeassistant/components/agent_dvr/translations/no.json index cbb4e3503a0..3fcbb8f1617 100644 --- a/homeassistant/components/agent_dvr/translations/no.json +++ b/homeassistant/components/agent_dvr/translations/no.json @@ -11,7 +11,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "title": "Konfigurere Agent DVR" } diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py index deeff9af00f..8ee4e1cd87b 100644 --- a/homeassistant/components/airly/air_quality.py +++ b/homeassistant/components/airly/air_quality.py @@ -18,7 +18,9 @@ from .const import ( ATTR_API_PM25, ATTR_API_PM25_LIMIT, ATTR_API_PM25_PERCENT, + DEFAULT_NAME, DOMAIN, + MANUFACTURER, ) ATTRIBUTION = "Data provided by Airly" @@ -31,6 +33,8 @@ LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit" LABEL_PM_10_LIMIT = f"{ATTR_PM_10}_limit" LABEL_PM_10_PERCENT = f"{ATTR_PM_10}_percent_of_limit" +PARALLEL_UPDATES = 1 + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Airly air_quality entity based on a config entry.""" @@ -38,9 +42,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [AirlyAirQuality(coordinator, name, config_entry.unique_id)], False - ) + async_add_entities([AirlyAirQuality(coordinator, name)], False) def round_state(func): @@ -58,11 +60,10 @@ def round_state(func): class AirlyAirQuality(AirQualityEntity): """Define an Airly air quality.""" - def __init__(self, coordinator, name, unique_id): + def __init__(self, coordinator, name): """Initialize.""" self.coordinator = coordinator self._name = name - self._unique_id = unique_id self._icon = "mdi:blur" @property @@ -106,7 +107,19 @@ class AirlyAirQuality(AirQualityEntity): @property def unique_id(self): """Return a unique_id for this entity.""" - return self._unique_id + return f"{self.coordinator.latitude}-{self.coordinator.longitude}" + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": { + (DOMAIN, self.coordinator.latitude, self.coordinator.longitude) + }, + "name": DEFAULT_NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } @property def available(self): diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index d7f8fc12797..dc21d68a8d8 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -15,5 +15,6 @@ ATTR_API_PRESSURE = "PRESSURE" ATTR_API_TEMPERATURE = "TEMPERATURE" DEFAULT_NAME = "Airly" DOMAIN = "airly" +MANUFACTURER = "Airly sp. z o.o." MAX_REQUESTS_PER_DAY = 100 NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet." diff --git a/homeassistant/components/airly/manifest.json b/homeassistant/components/airly/manifest.json index e86a187793f..8140bc91c5f 100644 --- a/homeassistant/components/airly/manifest.json +++ b/homeassistant/components/airly/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/airly", "codeowners": ["@bieniu"], "requirements": ["airly==0.0.2"], - "config_flow": true + "config_flow": true, + "quality_scale": "platinum" } diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index a0c5975188b..916405be2a5 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -18,7 +18,9 @@ from .const import ( ATTR_API_PM1, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, + DEFAULT_NAME, DOMAIN, + MANUFACTURER, ) ATTRIBUTION = "Data provided by Airly" @@ -27,6 +29,8 @@ ATTR_ICON = "icon" ATTR_LABEL = "label" ATTR_UNIT = "unit" +PARALLEL_UPDATES = 1 + SENSOR_TYPES = { ATTR_API_PM1: { ATTR_DEVICE_CLASS: None, @@ -63,8 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] for sensor in SENSOR_TYPES: - unique_id = f"{config_entry.unique_id}-{sensor.lower()}" - sensors.append(AirlySensor(coordinator, name, sensor, unique_id)) + sensors.append(AirlySensor(coordinator, name, sensor)) async_add_entities(sensors, False) @@ -72,11 +75,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AirlySensor(Entity): """Define an Airly sensor.""" - def __init__(self, coordinator, name, kind, unique_id): + def __init__(self, coordinator, name, kind): """Initialize.""" self.coordinator = coordinator self._name = name - self._unique_id = unique_id self.kind = kind self._device_class = None self._state = None @@ -123,7 +125,19 @@ class AirlySensor(Entity): @property def unique_id(self): """Return a unique_id for this entity.""" - return self._unique_id + return f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": { + (DOMAIN, self.coordinator.latitude, self.coordinator.longitude) + }, + "name": DEFAULT_NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } @property def unit_of_measurement(self): diff --git a/homeassistant/components/airly/translations/es.json b/homeassistant/components/airly/translations/es.json index 353acfe2fb8..dececf29a69 100644 --- a/homeassistant/components/airly/translations/es.json +++ b/homeassistant/components/airly/translations/es.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "Clave API de Airly", + "api_key": "Clave API", "latitude": "Latitud", "longitude": "Longitud", "name": "Nombre de la integraci\u00f3n" diff --git a/homeassistant/components/alarm_control_panel/translations/cs.json b/homeassistant/components/alarm_control_panel/translations/cs.json index 0eff1bebaae..40a6fd40338 100644 --- a/homeassistant/components/alarm_control_panel/translations/cs.json +++ b/homeassistant/components/alarm_control_panel/translations/cs.json @@ -1,25 +1,39 @@ { "device_automation": { "action_type": { - "arm_away": "Aktivovat {entity_name} v re\u017eimu mimo domov", - "arm_home": "Aktivovat {entity_name} v re\u017eimu doma", - "arm_night": "Aktivovat {entity_name} v re\u017eimu noc", - "disarm": "Deaktivovat {entity_name}", + "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", + "disarm": "Odbezpe\u010dit {entity_name}", "trigger": "Spustit {entity_name}" + }, + "condition_type": { + "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_disarmed": "{entity_name} nen\u00ed zabezpe\u010den", + "is_triggered": "{entity_name} je spu\u0161t\u011bn" + }, + "trigger_type": { + "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", + "disarmed": "{entity_name} nezabezpe\u010den", + "triggered": "{entity_name} spu\u0161t\u011bn" } }, "state": { "_": { - "armed": "Aktivn\u00ed", - "armed_away": "Aktivn\u00ed re\u017eim mimo domov", - "armed_custom_bypass": "Aktivn\u00ed u\u017eivatelsk\u00fdm obejit\u00edm", - "armed_home": "Aktivn\u00ed re\u017eim doma", - "armed_night": "Aktivn\u00ed no\u010dn\u00ed re\u017eim", - "arming": "Aktivov\u00e1n\u00ed", - "disarmed": "Neaktivn\u00ed", - "disarming": "Deaktivov\u00e1n\u00ed", - "pending": "Nadch\u00e1zej\u00edc\u00ed", - "triggered": "Spu\u0161t\u011bno" + "armed": "Zabezpe\u010deno", + "armed_away": "Re\u017eim nep\u0159\u00edtomnost", + "armed_custom_bypass": "Zabezpe\u010deno u\u017eivatelsk\u00fdm obejit\u00edm", + "armed_home": "Re\u017eim domov", + "armed_night": "No\u010dn\u00ed re\u017eim", + "arming": "Zabezpe\u010dov\u00e1n\u00ed", + "disarmed": "Nezabezpe\u010deno", + "disarming": "Odbezpe\u010dov\u00e1n\u00ed", + "pending": "\u010cekaj\u00edc\u00ed", + "triggered": "Spu\u0161t\u011bn" } }, "title": "Ovl\u00e1dac\u00ed panel alarmu" diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 38b9c5999be..117374552f3 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -84,6 +84,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): self._name = "Alarm Panel" self._state = None self._ac_power = None + self._alarm_event_occurred = None self._backlight_on = None self._battery_low = None self._check_zone = None @@ -117,6 +118,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): self._state = STATE_ALARM_DISARMED self._ac_power = message.ac_power + self._alarm_event_occurred = message.alarm_event_occurred self._backlight_on = message.backlight_on self._battery_low = message.battery_low self._check_zone = message.check_zone @@ -163,6 +165,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): """Return the state attributes.""" return { "ac_power": self._ac_power, + "alarm_event_occurred": self._alarm_event_occurred, "backlight_on": self._backlight_on, "battery_low": self._battery_low, "check_zone": self._check_zone, diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 120b83d7923..89b6236d392 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -293,7 +293,7 @@ async def async_setup_entry(hass, config_entry): Client( config_entry.data[CONF_API_KEY], config_entry.data[CONF_APP_KEY], - session, + session=session, ), ) hass.loop.create_task(ambient.ws_connect()) diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py index c363a2839fb..a4c0a6aa44f 100644 --- a/homeassistant/components/ambient_station/config_flow.py +++ b/homeassistant/components/ambient_station/config_flow.py @@ -43,7 +43,9 @@ class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() session = aiohttp_client.async_get_clientsession(self.hass) - client = Client(user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) + client = Client( + user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session=session + ) try: devices = await client.api.get_devices() diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index e73190bb580..916f1378fd0 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,6 +3,6 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.1.1"], + "requirements": ["aioambient==1.2.1"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 8971b04c044..e2468247a72 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -5,7 +5,6 @@ import logging import os from adb_shell.auth.keygen import keygen -from adb_shell.auth.sign_pythonrsa import PythonRSASigner from adb_shell.exceptions import ( AdbTimeoutError, InvalidChecksumError, @@ -14,6 +13,7 @@ from adb_shell.exceptions import ( TcpTimeoutException, ) from androidtv import ha_state_detection_rules_validator +from androidtv.adb_manager.adb_manager_sync import ADBPythonSync from androidtv.constants import APPS, KEYS from androidtv.exceptions import LockNotAcquiredException from androidtv.setup_async import setup @@ -40,6 +40,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, + EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_PAUSED, @@ -175,9 +176,7 @@ def setup_androidtv(hass, config): keygen(adbkey) # Load the ADB key - with open(adbkey) as priv_key: - priv = priv_key.read() - signer = PythonRSASigner("", priv) + signer = ADBPythonSync.load_adbkey(adbkey) adb_log = f"using Python ADB implementation with adbkey='{adbkey}'" else: @@ -230,6 +229,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) raise PlatformNotReady + async def _async_close(event): + """Close the ADB socket connection when HA stops.""" + await aftv.adb_close() + + # Close the ADB connection when HA stops + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) + device_args = [ aftv, config[CONF_NAME], diff --git a/homeassistant/components/arcam_fmj/translations/no.json b/homeassistant/components/arcam_fmj/translations/no.json index f52ef426bee..14d55224119 100644 --- a/homeassistant/components/arcam_fmj/translations/no.json +++ b/homeassistant/components/arcam_fmj/translations/no.json @@ -11,6 +11,10 @@ "description": "Vil du legge Arcam FMJ p\u00e5 ` {host} ` til Home Assistant? " }, "user": { + "data": { + "host": "Vert", + "port": "" + }, "description": "Vennligst skriv inn vertsnavnet eller IP-adressen til enheten." } } diff --git a/homeassistant/components/arcam_fmj/translations/pl.json b/homeassistant/components/arcam_fmj/translations/pl.json index 6a2c18cbd44..7b2d5da76e5 100644 --- a/homeassistant/components/arcam_fmj/translations/pl.json +++ b/homeassistant/components/arcam_fmj/translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/atag/translations/no.json b/homeassistant/components/atag/translations/no.json index 3f446a5f21b..a0e428f286a 100644 --- a/homeassistant/components/atag/translations/no.json +++ b/homeassistant/components/atag/translations/no.json @@ -12,7 +12,7 @@ "data": { "email": "E-post (valgfritt)", "host": "Vert", - "port": "Port " + "port": "" }, "title": "Koble til enheten" } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 2a05b1555c9..f7a6aecf03b 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -23,7 +23,13 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import Context, CoreState, HomeAssistant, callback +from homeassistant.core import ( + Context, + CoreState, + HomeAssistant, + callback, + split_entity_id, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import condition, extract_domain_configs import homeassistant.helpers.config_validation as cv @@ -61,6 +67,7 @@ CONF_TRIGGER = "trigger" CONF_CONDITION_TYPE = "condition_type" CONF_INITIAL_STATE = "initial_state" CONF_SKIP_CONDITION = "skip_condition" +CONF_STOP_ACTIONS = "stop_actions" CONDITION_USE_TRIGGER_VALUES = "use_trigger_values" CONDITION_TYPE_AND = "and" @@ -69,6 +76,7 @@ CONDITION_TYPE_OR = "or" DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND DEFAULT_INITIAL_STATE = True +DEFAULT_STOP_ACTIONS = True EVENT_AUTOMATION_RELOADED = "automation_reloaded" EVENT_AUTOMATION_TRIGGERED = "automation_triggered" @@ -219,7 +227,11 @@ async def async_setup(hass, config): ) component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") - component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") + component.async_register_entity_service( + SERVICE_TURN_OFF, + {vol.Optional(CONF_STOP_ACTIONS, default=DEFAULT_STOP_ACTIONS): cv.boolean}, + "async_turn_off", + ) async def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" @@ -255,11 +267,13 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._async_detach_triggers = None self._cond_func = cond_func self.action_script = action_script + self.action_script.change_listener = self.async_write_ha_state self._last_triggered = None self._initial_state = initial_state self._is_enabled = False self._referenced_entities: Optional[Set[str]] = None self._referenced_devices: Optional[Set[str]] = None + self._logger = _LOGGER @property def name(self): @@ -282,11 +296,10 @@ class AutomationEntity(ToggleEntity, RestoreEntity): attrs = { ATTR_LAST_TRIGGERED: self._last_triggered, ATTR_MODE: self.action_script.script_mode, + ATTR_CUR: self.action_script.runs, } if self.action_script.supports_max: attrs[ATTR_MAX] = self.action_script.max_runs - if self.is_on: - attrs[ATTR_CUR] = self.action_script.runs return attrs @property @@ -337,13 +350,18 @@ class AutomationEntity(ToggleEntity, RestoreEntity): """Startup with initial state or previous state.""" await super().async_added_to_hass() + self._logger = logging.getLogger( + f"{__name__}.{split_entity_id(self.entity_id)[1]}" + ) + self.action_script.update_logger(self._logger) + state = await self.async_get_last_state() if state: enable_automation = state.state == STATE_ON last_triggered = state.attributes.get("last_triggered") if last_triggered is not None: self._last_triggered = parse_datetime(last_triggered) - _LOGGER.debug( + self._logger.debug( "Loaded automation %s with state %s from state " " storage last state %s", self.entity_id, @@ -352,7 +370,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): ) else: enable_automation = DEFAULT_INITIAL_STATE - _LOGGER.debug( + self._logger.debug( "Automation %s not in state storage, state %s from default is used", self.entity_id, enable_automation, @@ -360,7 +378,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): if self._initial_state is not None: enable_automation = self._initial_state - _LOGGER.debug( + self._logger.debug( "Automation %s initial state %s overridden from " "config initial_state", self.entity_id, @@ -376,7 +394,10 @@ class AutomationEntity(ToggleEntity, RestoreEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.async_disable() + if CONF_STOP_ACTIONS in kwargs: + await self.async_disable(kwargs[CONF_STOP_ACTIONS]) + else: + await self.async_disable() async def async_trigger(self, variables, skip_condition=False, context=None): """Trigger automation. @@ -403,12 +424,12 @@ class AutomationEntity(ToggleEntity, RestoreEntity): context=trigger_context, ) - _LOGGER.info("Executing %s", self._name) + self._logger.info("Executing %s", self._name) try: await self.action_script.async_run(variables, trigger_context) except Exception: # pylint: disable=broad-except - _LOGGER.exception("While executing automation %s", self.entity_id) + self._logger.exception("While executing automation %s", self.entity_id) async def async_will_remove_from_hass(self): """Remove listeners when removing automation from Home Assistant.""" @@ -444,9 +465,9 @@ class AutomationEntity(ToggleEntity, RestoreEntity): ) self.async_write_ha_state() - async def async_disable(self): + async def async_disable(self, stop_actions=DEFAULT_STOP_ACTIONS): """Disable the automation entity.""" - if not self._is_enabled: + if not self._is_enabled and not self.action_script.runs: return self._is_enabled = False @@ -455,7 +476,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._async_detach_triggers() self._async_detach_triggers = None - await self.action_script.async_stop() + if stop_actions: + await self.action_script.async_stop() self.async_write_ha_state() @@ -478,13 +500,13 @@ class AutomationEntity(ToggleEntity, RestoreEntity): results = await asyncio.gather(*triggers) if None in results: - _LOGGER.error("Error setting up trigger %s", self._name) + self._logger.error("Error setting up trigger %s", self._name) removes = [remove for remove in results if remove is not None] if not removes: return None - _LOGGER.info("Initialized trigger %s", self._name) + self._logger.info("Initialized trigger %s", self._name) @callback def remove_triggers(): diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml index 867dc8e89cd..2f5b0a231e4 100644 --- a/homeassistant/components/automation/services.yaml +++ b/homeassistant/components/automation/services.yaml @@ -12,6 +12,9 @@ turn_off: entity_id: description: Name of the automation to turn off. example: "automation.notify_home" + stop_actions: + description: Stop currently running actions (defaults to true). + example: false toggle: description: Toggle an automation. @@ -27,7 +30,7 @@ trigger: description: Name of the automation to trigger. example: "automation.notify_home" skip_condition: - description: Whether or not the condition will be skipped (defaults to True). + description: Whether or not the condition will be skipped (defaults to true). example: true reload: diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index fe49e1cf532..9d504d40de5 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -73,16 +73,13 @@ async def async_attach_trigger( from_s = event.data.get("old_state") to_s = event.data.get("new_state") + old_state = getattr(from_s, "state", None) + new_state = getattr(to_s, "state", None) if ( - (from_s is not None and not match_from_state(from_s.state)) - or (to_s is not None and not match_to_state(to_s.state)) - or ( - not match_all - and from_s is not None - and to_s is not None - and from_s.state == to_s.state - ) + not match_from_state(old_state) + or not match_to_state(new_state) + or (not match_all and old_state == new_state) ): return @@ -104,15 +101,6 @@ async def async_attach_trigger( ) ) - # Ignore changes to state attributes if from/to is in use - if ( - not match_all - and from_s is not None - and to_s is not None - and from_s.state == to_s.state - ): - return - if not time_delta: call_action() return diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 5f461952960..f59ceff81ea 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -13,20 +13,37 @@ from homeassistant.helpers.event import async_track_time_change _LOGGER = logging.getLogger(__name__) TRIGGER_SCHEMA = vol.Schema( - {vol.Required(CONF_PLATFORM): "time", vol.Required(CONF_AT): cv.time} + { + vol.Required(CONF_PLATFORM): "time", + vol.Required(CONF_AT): vol.All(cv.ensure_list, [cv.time]), + } ) async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - at_time = config.get(CONF_AT) - hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second + at_times = config[CONF_AT] @callback def time_automation_listener(now): """Listen for time changes and calls action.""" hass.async_run_job(action, {"trigger": {"platform": "time", "now": now}}) - return async_track_time_change( - hass, time_automation_listener, hour=hours, minute=minutes, second=seconds - ) + removes = [ + async_track_time_change( + hass, + time_automation_listener, + hour=at_time.hour, + minute=at_time.minute, + second=at_time.second, + ) + for at_time in at_times + ] + + @callback + def remove_track_time_changes(): + """Remove tracked time changes.""" + for remove in removes: + remove() + + return remove_track_time_changes diff --git a/homeassistant/components/awair/translations/no.json b/homeassistant/components/awair/translations/no.json index afce9147d0b..46c3d5c3711 100644 --- a/homeassistant/components/awair/translations/no.json +++ b/homeassistant/components/awair/translations/no.json @@ -1,17 +1,25 @@ { "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "no_devices": "Ingen enheter funnet p\u00e5 nettverket", + "reauth_successful": "Tilgangstoken oppdatert" + }, "error": { + "auth": "Ugyldig tilgangstoken", "unknown": "Ukjent Awair API-feil." }, "step": { "reauth": { "data": { + "access_token": "Tilgangstoken", "email": "Epost" }, "description": "Skriv inn tilgangstokenet for Awair-utviklere p\u00e5 nytt." }, "user": { "data": { + "access_token": "Tilgangstoken", "email": "Epost " }, "description": "Du m\u00e5 registrere deg for et Awair-utviklertilgangstoken p\u00e5: https://developer.getawair.com/onboard/login" diff --git a/homeassistant/components/awair/translations/pl.json b/homeassistant/components/awair/translations/pl.json new file mode 100644 index 00000000000..07983402c42 --- /dev/null +++ b/homeassistant/components/awair/translations/pl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane.", + "no_devices": "Nie znaleziono urz\u0105dze\u0144 w sieci.", + "reauth_successful": "Token dost\u0119pu pomy\u015blnie zaktualizowano." + }, + "error": { + "auth": "Token dost\u0119pu" + }, + "step": { + "reauth": { + "data": { + "access_token": "Token dost\u0119pu", + "email": "Adres e-mail" + } + }, + "user": { + "data": { + "access_token": "Token dost\u0119pu", + "email": "Adres e-mail" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/translations/no.json b/homeassistant/components/axis/translations/no.json index 891b4b6d972..039e6138753 100644 --- a/homeassistant/components/axis/translations/no.json +++ b/homeassistant/components/axis/translations/no.json @@ -18,7 +18,7 @@ "data": { "host": "Vert", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn" }, "title": "Sett opp Axis enhet" diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py new file mode 100644 index 00000000000..00f08496dd3 --- /dev/null +++ b/homeassistant/components/azure_devops/__init__.py @@ -0,0 +1,121 @@ +"""Support for Azure DevOps.""" +import logging +from typing import Any, Dict + +from aioazuredevops.client import DevOpsClient +import aiohttp + +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DATA_AZURE_DEVOPS_CLIENT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the Azure DevOps components.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up Azure DevOps from a config entry.""" + client = DevOpsClient() + + try: + if entry.data[CONF_PAT] is not None: + await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) + if not client.authorized: + _LOGGER.warning( + "Could not authorize with Azure DevOps. You may need to update your token" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=entry.data, + ) + ) + return False + await client.get_project(entry.data[CONF_ORG], entry.data[CONF_PROJECT]) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise ConfigEntryNotReady from exception + + instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" + hass.data.setdefault(instance_key, {})[DATA_AZURE_DEVOPS_CLIENT] = client + + # Setup components + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: + """Unload Azure DevOps config entry.""" + del hass.data[f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}"] + + return await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + +class AzureDevOpsEntity(Entity): + """Defines a base Azure DevOps entity.""" + + def __init__(self, organization: str, project: str, name: str, icon: str) -> None: + """Initialize the Azure DevOps entity.""" + self._name = name + self._icon = icon + self._available = True + self.organization = organization + self.project = project + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self) -> None: + """Update Azure DevOps entity.""" + if await self._azure_devops_update(): + self._available = True + else: + if self._available: + _LOGGER.debug( + "An error occurred while updating Azure DevOps sensor.", + exc_info=True, + ) + self._available = False + + async def _azure_devops_update(self) -> None: + """Update Azure DevOps entity.""" + raise NotImplementedError() + + +class AzureDevOpsDeviceEntity(AzureDevOpsEntity): + """Defines a Azure DevOps device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this Azure DevOps instance.""" + return { + "identifiers": {(DOMAIN, self.organization, self.project,)}, + "manufacturer": self.organization, + "name": self.project, + } diff --git a/homeassistant/components/azure_devops/config_flow.py b/homeassistant/components/azure_devops/config_flow.py new file mode 100644 index 00000000000..69030871b8d --- /dev/null +++ b/homeassistant/components/azure_devops/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow to configure the Azure DevOps integration.""" +import logging + +from aioazuredevops.client import DevOpsClient +import aiohttp +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.azure_devops.const import ( # pylint:disable=unused-import + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DOMAIN, +) +from homeassistant.config_entries import ConfigFlow + +_LOGGER = logging.getLogger(__name__) + + +class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Azure DevOps config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize config flow.""" + self._organization = None + self._project = None + self._pat = None + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ORG, default=self._organization): str, + vol.Required(CONF_PROJECT, default=self._project): str, + vol.Optional(CONF_PAT): str, + } + ), + errors=errors or {}, + ) + + async def _show_reauth_form(self, errors=None): + """Show the reauth form to the user.""" + return self.async_show_form( + step_id="reauth", + description_placeholders={ + "project_url": f"{self._organization}/{self._project}" + }, + data_schema=vol.Schema({vol.Required(CONF_PAT): str}), + errors=errors or {}, + ) + + async def _check_setup(self): + """Check the setup of the flow.""" + errors = {} + + client = DevOpsClient() + + try: + if self._pat is not None: + await client.authorize(self._pat, self._organization) + if not client.authorized: + errors["base"] = "authorization_error" + return errors + project_info = await client.get_project(self._organization, self._project) + if project_info is None: + errors["base"] = "project_error" + return errors + except aiohttp.ClientError: + errors["base"] = "connection_error" + return errors + return None + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return await self._show_setup_form(user_input) + + self._organization = user_input[CONF_ORG] + self._project = user_input[CONF_PROJECT] + self._pat = user_input.get(CONF_PAT) + + await self.async_set_unique_id(f"{self._organization}_{self._project}") + self._abort_if_unique_id_configured() + + errors = await self._check_setup() + if errors is not None: + return await self._show_setup_form(errors) + return self._async_create_entry() + + async def async_step_reauth(self, user_input): + """Handle configuration by re-auth.""" + if user_input.get(CONF_ORG) and user_input.get(CONF_PROJECT): + self._organization = user_input[CONF_ORG] + self._project = user_input[CONF_PROJECT] + self._pat = user_input[CONF_PAT] + + # pylint: disable=no-member + self.context["title_placeholders"] = { + "project_url": f"{self._organization}/{self._project}", + } + + await self.async_set_unique_id(f"{self._organization}_{self._project}") + + errors = await self._check_setup() + if errors is not None: + return await self._show_reauth_form(errors) + + for entry in self._async_current_entries(): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry( + entry, + data={ + CONF_ORG: self._organization, + CONF_PROJECT: self._project, + CONF_PAT: self._pat, + }, + ) + return self.async_abort(reason="reauth_successful") + + def _async_create_entry(self): + """Handle create entry.""" + return self.async_create_entry( + title=f"{self._organization}/{self._project}", + data={ + CONF_ORG: self._organization, + CONF_PROJECT: self._project, + CONF_PAT: self._pat, + }, + ) diff --git a/homeassistant/components/azure_devops/const.py b/homeassistant/components/azure_devops/const.py new file mode 100644 index 00000000000..40610ba7baa --- /dev/null +++ b/homeassistant/components/azure_devops/const.py @@ -0,0 +1,11 @@ +"""Constants for the Azure DevOps integration.""" +DOMAIN = "azure_devops" + +DATA_AZURE_DEVOPS_CLIENT = "azure_devops_client" +DATA_ORG = "organization" +DATA_PROJECT = "project" +DATA_PAT = "personal_access_token" + +CONF_ORG = "organization" +CONF_PROJECT = "project" +CONF_PAT = "personal_access_token" diff --git a/homeassistant/components/azure_devops/manifest.json b/homeassistant/components/azure_devops/manifest.json new file mode 100644 index 00000000000..17338f5a29f --- /dev/null +++ b/homeassistant/components/azure_devops/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "azure_devops", + "name": "Azure DevOps", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/azure_devops", + "requirements": ["aioazuredevops==1.3.5"], + "codeowners": ["@timmo001"] +} diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py new file mode 100644 index 00000000000..6f259afb9a9 --- /dev/null +++ b/homeassistant/components/azure_devops/sensor.py @@ -0,0 +1,148 @@ +"""Support for Azure DevOps sensors.""" +from datetime import timedelta +import logging +from typing import List + +from aioazuredevops.builds import DevOpsBuild +from aioazuredevops.client import DevOpsClient +import aiohttp + +from homeassistant.components.azure_devops import AzureDevOpsDeviceEntity +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PROJECT, + DATA_AZURE_DEVOPS_CLIENT, + DATA_ORG, + DATA_PROJECT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + +BUILDS_QUERY = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up Azure DevOps sensor based on a config entry.""" + instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" + client = hass.data[instance_key][DATA_AZURE_DEVOPS_CLIENT] + organization = entry.data[DATA_ORG] + project = entry.data[DATA_PROJECT] + sensors = [] + + try: + builds: List[DevOpsBuild] = await client.get_builds( + organization, project, BUILDS_QUERY + ) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise PlatformNotReady from exception + + for build in builds: + sensors.append( + AzureDevOpsLatestBuildSensor(client, organization, project, build) + ) + + async_add_entities(sensors, True) + + +class AzureDevOpsSensor(AzureDevOpsDeviceEntity): + """Defines a Azure DevOps sensor.""" + + def __init__( + self, + client: DevOpsClient, + organization: str, + project: str, + key: str, + name: str, + icon: str, + measurement: str = "", + unit_of_measurement: str = "", + ) -> None: + """Initialize Azure DevOps sensor.""" + self._state = None + self._attributes = None + self._available = False + self._unit_of_measurement = unit_of_measurement + self.measurement = measurement + self.client = client + self.organization = organization + self.project = project + self.key = key + + super().__init__(organization, project, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return "_".join([self.organization, self.key]) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + return self._attributes + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): + """Defines a Azure DevOps card count sensor.""" + + def __init__( + self, client: DevOpsClient, organization: str, project: str, build: DevOpsBuild + ): + """Initialize Azure DevOps sensor.""" + self.build: DevOpsBuild = build + super().__init__( + client, + organization, + project, + f"{build.project.id}_{build.definition.id}_latest_build", + f"{build.project.name} {build.definition.name} Latest Build", + "mdi:pipe", + ) + + async def _azure_devops_update(self) -> bool: + """Update Azure DevOps entity.""" + try: + build: DevOpsBuild = await self.client.get_build( + self.organization, self.project, self.build.id + ) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + self._available = False + return False + self._state = build.build_number + self._attributes = { + "definition_id": build.definition.id, + "definition_name": build.definition.name, + "id": build.id, + "reason": build.reason, + "result": build.result, + "source_branch": build.source_branch, + "source_version": build.source_version, + "status": build.status, + "url": build.links.web, + "queue_time": build.queue_time, + "start_time": build.start_time, + "finish_time": build.finish_time, + } + self._available = True + return True diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json new file mode 100644 index 00000000000..2bb53010153 --- /dev/null +++ b/homeassistant/components/azure_devops/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "flow_title": "Azure DevOps: {project_url}", + "error": { + "authorization_error": "Authorization error. Check you have access to the project and have the correct credentials.", + "connection_error": "Could not connect to Azure DevOps.", + "project_error": "Could not get project info." + }, + "step": { + "user": { + "data": { + "organization": "Organization", + "project": "Project", + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", + "title": "Add Azure DevOps Project" + }, + "reauth": { + "data": { + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Authentication failed for {project_url}. Please enter your current credentials.", + "title": "Reauthentication" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully" + } + }, + "title": "Azure DevOps" +} diff --git a/homeassistant/components/azure_devops/translations/ca.json b/homeassistant/components/azure_devops/translations/ca.json new file mode 100644 index 00000000000..df8fe8c04b3 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/ca.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat", + "reauth_successful": "Token d'acc\u00e9s actualitzat correctament" + }, + "error": { + "authorization_error": "Error d'autoritzaci\u00f3. Comprova que tens acc\u00e9s al projecte i tens les credencials correctes.", + "connection_error": "No s'ha pogut connectar a Azure DevOps.", + "project_error": "No s'ha pogut obtenir la informaci\u00f3 del projecte." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token d'Acc\u00e9s Personal (PAT)" + }, + "description": "L'autenticaci\u00f3 de {project_url} ha fallat. Si us plau, introdueix les teves credencials actuals.", + "title": "Reautenticaci\u00f3" + }, + "user": { + "data": { + "organization": "Organitzaci\u00f3", + "personal_access_token": "Token d'Acc\u00e9s Personal (PAT)", + "project": "Projecte" + }, + "description": "Configura una inst\u00e0ncia d'Azure DevOps per accedir al teu projecte. El token d'acc\u00e9s personal nom\u00e9s \u00e9s necessari per a projectes privats.", + "title": "Afegeix un projecte Azure DevOps" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/en.json b/homeassistant/components/azure_devops/translations/en.json new file mode 100644 index 00000000000..3adeade6c5f --- /dev/null +++ b/homeassistant/components/azure_devops/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Access Token updated successfully" + }, + "error": { + "authorization_error": "Authorization error. Check you have access to the project and have the correct credentials.", + "connection_error": "Could not connect to Azure DevOps.", + "project_error": "Could not get project info." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Authentication failed for {project_url}. Please enter your current credentials.", + "title": "Reauthentication" + }, + "user": { + "data": { + "organization": "Organization", + "personal_access_token": "Personal Access Token (PAT)", + "project": "Project" + }, + "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", + "title": "Add Azure DevOps Project" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/es.json b/homeassistant/components/azure_devops/translations/es.json new file mode 100644 index 00000000000..ccf4658d727 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/es.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya ha sido configurada", + "reauth_successful": "Token de acceso actualizado correctamente " + }, + "error": { + "authorization_error": "Error de autorizaci\u00f3n. Comprueba que tienes acceso al proyecto y las credenciales son correctas.", + "connection_error": "No se pudo conectar con Azure DevOps", + "project_error": "No se pudo obtener informaci\u00f3n del proyecto." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token Personal de Acceso (PAT)" + }, + "description": "Error de autenticaci\u00f3n para {project_url}. Por favor, introduce tus credenciales actuales.", + "title": "Reautenticaci\u00f3n" + }, + "user": { + "data": { + "organization": "Organizaci\u00f3n", + "personal_access_token": "Token Personal de Acceso (PAT)", + "project": "Proyecto" + }, + "description": "Configura una instancia de Azure DevOps para acceder a tu proyecto. Un Token Personal de Acceso s\u00f3lo es necesario para un proyecto privado.", + "title": "A\u00f1adir Proyecto Azure DevOps" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/it.json b/homeassistant/components/azure_devops/translations/it.json new file mode 100644 index 00000000000..a62f366e724 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/it.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "Token di accesso aggiornato correttamente" + }, + "error": { + "authorization_error": "Errore di autorizzazione. Verificare di avere accesso al progetto e disporre delle credenziali corrette.", + "connection_error": "Impossibile connettersi ad Azure DevOps.", + "project_error": "Non \u00e8 stato possibile ottenere informazioni sul progetto." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token di Accesso Personale (PAT)" + }, + "description": "Autenticazione non riuscita per {project_url}. Si prega di inserire le proprie credenziali attuali.", + "title": "Riautenticazione" + }, + "user": { + "data": { + "organization": "Organizzazione", + "personal_access_token": "Token di Accesso Personale (PAT)", + "project": "Progetto" + }, + "description": "Configurare un'istanza di DevOps di Azure per accedere al progetto. Un Token di Accesso Personale (PAT) \u00e8 richiesto solo per un progetto privato.", + "title": "Aggiungere un progetto Azure DevOps" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/lb.json b/homeassistant/components/azure_devops/translations/lb.json new file mode 100644 index 00000000000..ae6df660cff --- /dev/null +++ b/homeassistant/components/azure_devops/translations/lb.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Kont ass scho konfigur\u00e9iert", + "reauth_successful": "Acc\u00e8s Jeton erfollegr\u00e4ich aktualis\u00e9iert" + }, + "error": { + "authorization_error": "Feeler bei der Authorisatioun. Iwwerpr\u00e9if ob d\u00e4in Kont den acc\u00e8s zum Projet souw\u00e9i d\u00e9i richteg Umeldungsinformatioune huet", + "connection_error": "Konnt sech net mat Azure DevOps verbannen", + "project_error": "Konnt keng Projet Informatiounen ausliesen." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Pers\u00e9inlechen Acc\u00e8s Jeton (PAT)" + }, + "description": "Feeler bei der Authentifikatioun fir {project_url}. G\u00ebff deng aktuell Umeldungsinformatiounen an.", + "title": "Reauthentifikatioun" + }, + "user": { + "data": { + "organization": "Organisatioun", + "personal_access_token": "Pers\u00e9inlechen Acc\u00e8s Jeton (PAT)", + "project": "Projet" + }, + "description": "Riicht eng Azure DevOps Instanz an fir d\u00e4in Projet z'acc\u00e9d\u00e9ieren. E Pers\u00e9inlechen Acc\u00e8s Jetons ass n\u00ebmme fir ee Private Projet n\u00e9ideg.", + "title": "Azure DevOps Project dob\u00e4isetzen" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/no.json b/homeassistant/components/azure_devops/translations/no.json new file mode 100644 index 00000000000..00d9ae4d925 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/no.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Tilgangstoken oppdatert" + }, + "error": { + "authorization_error": "Autoriseringsfeil. Sjekk at du har tilgang til prosjektet og har riktig legitimasjon.", + "connection_error": "Kunne ikke koble til Azure DevOps.", + "project_error": "Kunne ikke f\u00e5 prosjektinformasjon." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token for personlig tilgang (PAT)" + }, + "description": "Autentiseringen mislyktes for {project_url} . Vennligst skriv inn gjeldende legitimasjon.", + "title": "reautentisering" + }, + "user": { + "data": { + "organization": "Organisasjon", + "personal_access_token": "Token for personlig tilgang (PAT)", + "project": "Prosjekt" + }, + "description": "Sett opp en Azure DevOps-forekomst for \u00e5 f\u00e5 tilgang til prosjektet ditt. En personlig tilgangstoken er bare n\u00f8dvendig for et privat prosjekt.", + "title": "Legg til Azure DevOps Project" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/ru.json b/homeassistant/components/azure_devops/translations/ru.json new file mode 100644 index 00000000000..08e07d68560 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/ru.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430.", + "reauth_successful": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d." + }, + "error": { + "authorization_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0443 \u0412\u0430\u0441 \u0435\u0441\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u043f\u0440\u043e\u0435\u043a\u0442\u0443, \u0430 \u0442\u0430\u043a \u0436\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a Azure DevOps.", + "project_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0435." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (PAT)" + }, + "description": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 {project_url}. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "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": { + "organization": "\u041e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f", + "personal_access_token": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (PAT)", + "project": "\u041f\u0440\u043e\u0435\u043a\u0442" + }, + "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 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u043c Azure DevOps. \u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u0432.", + "title": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u043e\u0435\u043a\u0442 Azure DevOps" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/uk.json b/homeassistant/components/azure_devops/translations/uk.json new file mode 100644 index 00000000000..f447cfb1a81 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/uk.json @@ -0,0 +1,19 @@ +{ + "config": { + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f" + }, + "user": { + "data": { + "organization": "\u041e\u0440\u0433\u0430\u043d\u0456\u0437\u0430\u0446\u0456\u044f", + "personal_access_token": "\u0422\u043e\u043a\u0435\u043d \u043e\u0441\u043e\u0431\u0438\u0441\u0442\u043e\u0433\u043e \u0434\u043e\u0441\u0442\u0443\u043f\u0443 (PAT)", + "project": "\u041f\u0440\u043e\u0454\u043a\u0442" + }, + "title": "\u0414\u043e\u0434\u0430\u0442\u0438 \u043f\u0440\u043e\u0435\u043a\u0442 Azure DevOps" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/zh-Hant.json b/homeassistant/components/azure_devops/translations/zh-Hant.json new file mode 100644 index 00000000000..f632ea947ae --- /dev/null +++ b/homeassistant/components/azure_devops/translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u5b58\u53d6\u5bc6\u9470\u5df2\u6210\u529f\u66f4\u65b0" + }, + "error": { + "authorization_error": "\u8a8d\u8b49\u932f\u8aa4\u3002\u8acb\u78ba\u8a8d\u64c1\u6709\u5c08\u6848\u5b58\u53d6\u6b0a\u8207\u6b63\u78ba\u7684\u8b49\u66f8\u3002", + "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 Azure DevOps\u3002", + "project_error": "\u7121\u6cd5\u53d6\u5f97\u5c08\u6848\u8cc7\u8a0a\u3002" + }, + "flow_title": "Azure DevOps\uff1a{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff08PAT\uff09" + }, + "description": "{project_url}\u8a8d\u8b49\u5931\u6557\u3002\u8acb\u8f38\u5165\u76ee\u524d\u8b49\u66f8\u3002", + "title": "\u91cd\u65b0\u8a8d\u8b49" + }, + "user": { + "data": { + "organization": "\u7d44\u7e54", + "personal_access_token": "\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff08PAT\uff09", + "project": "\u5c08\u6848" + }, + "description": "\u8a2d\u5b9a Azure DevOps \u4ee5\u5b58\u53d6\u5c08\u6848\u3002\u79c1\u4eba\u5c08\u6848\u5247\u9700\u8981\u8f38\u5165\u300c\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff09\u3002", + "title": "\u65b0\u589e Azure DevOps \u5c08\u6848" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/es.json b/homeassistant/components/binary_sensor/translations/es.json index 75b8a33026f..a3e48832468 100644 --- a/homeassistant/components/binary_sensor/translations/es.json +++ b/homeassistant/components/binary_sensor/translations/es.json @@ -131,7 +131,7 @@ "on": "H\u00famedo" }, "motion": { - "off": "Sin movimiento", + "off": "No detectado", "on": "Detectado" }, "occupancy": { diff --git a/homeassistant/components/blebox/translations/nl.json b/homeassistant/components/blebox/translations/nl.json new file mode 100644 index 00000000000..c7156a5f553 --- /dev/null +++ b/homeassistant/components/blebox/translations/nl.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "IP-adres", + "port": "Poort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blebox/translations/no.json b/homeassistant/components/blebox/translations/no.json index 03f054687ae..239d1fb03c6 100644 --- a/homeassistant/components/blebox/translations/no.json +++ b/homeassistant/components/blebox/translations/no.json @@ -14,7 +14,7 @@ "user": { "data": { "host": "IP adresse", - "port": "Port" + "port": "" }, "description": "Konfigurer BleBox-en til \u00e5 integreres med Home Assistant.", "title": "Konfigurere BleBox-enheten" diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 2344ce7b432..e2c43a27547 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -1,32 +1,25 @@ """Support for Blink Home Camera System.""" import asyncio +from copy import deepcopy import logging +from blinkpy.auth import Auth from blinkpy.blinkpy import Blink import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_FILENAME, - CONF_NAME, - CONF_PASSWORD, - CONF_PIN, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv - -from .const import ( - DEFAULT_OFFSET, +from homeassistant.components import persistent_notification +from homeassistant.components.blink.const import ( DEFAULT_SCAN_INTERVAL, - DEVICE_ID, DOMAIN, PLATFORMS, SERVICE_REFRESH, SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) +from homeassistant.const import CONF_FILENAME, CONF_NAME, CONF_PIN, CONF_SCAN_INTERVAL +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -35,58 +28,50 @@ SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( ) SERVICE_SEND_PIN_SCHEMA = vol.Schema({vol.Optional(CONF_PIN): cv.string}) -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): int, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -def _blink_startup_wrapper(entry): +def _blink_startup_wrapper(hass, entry): """Startup wrapper for blink.""" - blink = Blink( - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - motion_interval=DEFAULT_OFFSET, - legacy_subdomain=False, - no_prompt=True, - device_id=DEVICE_ID, - ) + blink = Blink() + auth_data = deepcopy(dict(entry.data)) + blink.auth = Auth(auth_data, no_prompt=True) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - try: - blink.login_response = entry.data["login_response"] - blink.setup_params(entry.data["login_response"]) - except KeyError: - blink.get_auth_token() + if blink.start(): + blink.setup_post_verify() + elif blink.auth.check_key_required(): + _LOGGER.debug("Attempting a reauth flow") + _reauth_flow_wrapper(hass, auth_data) - blink.setup_params(entry.data["login_response"]) - blink.setup_post_verify() return blink -async def async_setup(hass, config): - """Set up a config entry.""" - hass.data[DOMAIN] = {} - if DOMAIN not in config: - return True - - conf = config.get(DOMAIN, {}) - - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) +def _reauth_flow_wrapper(hass, data): + """Reauth flow wrapper.""" + hass.add_job( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=data ) + ) + persistent_notification.async_create( + hass, + "Blink configuration migrated to a new version. Please go to the integrations page to re-configure (such as sending a new 2FA key).", + "Blink Migration", + ) + +async def async_setup(hass, config): + """Set up a Blink component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_migrate_entry(hass, entry): + """Handle migration of a previous version config entry.""" + data = {**entry.data} + if entry.version == 1: + data.pop("login_response", None) + await hass.async_add_executor_job(_reauth_flow_wrapper, hass, data) + return False return True @@ -95,12 +80,11 @@ async def async_setup_entry(hass, entry): _async_import_options_from_data_if_missing(hass, entry) hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( - _blink_startup_wrapper, entry + _blink_startup_wrapper, hass, entry ) if not hass.data[DOMAIN][entry.entry_id].available: - _LOGGER.error("Blink unavailable for setup") - return False + raise ConfigEntryNotReady for component in PLATFORMS: hass.async_create_task( @@ -118,7 +102,7 @@ async def async_setup_entry(hass, entry): def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] - hass.data[DOMAIN][entry.entry_id].login_handler.send_auth_key( + hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( hass.data[DOMAIN][entry.entry_id], pin, ) diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 4cd89175ab6..3073a093261 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -1,10 +1,16 @@ """Config flow to configure Blink.""" import logging -from blinkpy.blinkpy import Blink +from blinkpy.auth import Auth, LoginError, TokenRefreshFailed +from blinkpy.blinkpy import Blink, BlinkSetupError import voluptuous as vol from homeassistant import config_entries, core, exceptions +from homeassistant.components.blink.const import ( + DEFAULT_SCAN_INTERVAL, + DEVICE_ID, + DOMAIN, +) from homeassistant.const import ( CONF_PASSWORD, CONF_PIN, @@ -13,36 +19,36 @@ from homeassistant.const import ( ) from homeassistant.core import callback -from .const import DEFAULT_OFFSET, DEFAULT_SCAN_INTERVAL, DEVICE_ID, DOMAIN - _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: core.HomeAssistant, blink): +def validate_input(hass: core.HomeAssistant, auth): """Validate the user input allows us to connect.""" - response = await hass.async_add_executor_job(blink.get_auth_token) - if not response: + try: + auth.startup() + except (LoginError, TokenRefreshFailed): raise InvalidAuth - if blink.key_required: + if auth.check_key_required(): raise Require2FA - return blink.login_response + +def _send_blink_2fa_pin(auth, pin): + """Send 2FA pin to blink servers.""" + blink = Blink() + blink.auth = auth + blink.setup_urls() + return auth.send_auth_key(blink, pin) class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Blink config flow.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL def __init__(self): """Initialize the blink flow.""" - self.blink = None - self.data = { - CONF_USERNAME: "", - CONF_PASSWORD: "", - "login_response": None, - } + self.auth = None @staticmethod @callback @@ -53,28 +59,19 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" errors = {} + data = {CONF_USERNAME: "", CONF_PASSWORD: "", "device_id": DEVICE_ID} if user_input is not None: - self.data[CONF_USERNAME] = user_input["username"] - self.data[CONF_PASSWORD] = user_input["password"] + data[CONF_USERNAME] = user_input["username"] + data[CONF_PASSWORD] = user_input["password"] - await self.async_set_unique_id(self.data[CONF_USERNAME]) - - if CONF_SCAN_INTERVAL in user_input: - self.data[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL] - - self.blink = Blink( - username=self.data[CONF_USERNAME], - password=self.data[CONF_PASSWORD], - motion_interval=DEFAULT_OFFSET, - legacy_subdomain=False, - no_prompt=True, - device_id=DEVICE_ID, - ) + self.auth = Auth(data, no_prompt=True) + await self.async_set_unique_id(data[CONF_USERNAME]) try: - response = await validate_input(self.hass, self.blink) - self.data["login_response"] = response - return self.async_create_entry(title=DOMAIN, data=self.data,) + await self.hass.async_add_executor_job( + validate_input, self.hass, self.auth + ) + return self._async_finish_flow() except Require2FA: return await self.async_step_2fa() except InvalidAuth: @@ -94,23 +91,40 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_2fa(self, user_input=None): """Handle 2FA step.""" + errors = {} if user_input is not None: pin = user_input.get(CONF_PIN) - if await self.hass.async_add_executor_job( - self.blink.login_handler.send_auth_key, self.blink, pin - ): - return await self.async_step_user(user_input=self.data) + try: + valid_token = await self.hass.async_add_executor_job( + _send_blink_2fa_pin, self.auth, pin + ) + except BlinkSetupError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + if valid_token: + return self._async_finish_flow() + errors["base"] = "invalid_access_token" return self.async_show_form( step_id="2fa", data_schema=vol.Schema( {vol.Optional("pin"): vol.All(str, vol.Length(min=1))} ), + errors=errors, ) - async def async_step_import(self, import_data): - """Import blink config from configuration.yaml.""" - return await self.async_step_user(import_data) + async def async_step_reauth(self, entry_data): + """Perform reauth upon migration of old entries.""" + return await self.async_step_user(entry_data) + + @callback + def _async_finish_flow(self): + """Finish with setup.""" + return self.async_create_entry(title=DOMAIN, data=self.auth.login_attributes) class BlinkOptionsFlowHandler(config_entries.OptionsFlow): diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 5ce22d10914..c93adbec46b 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -2,6 +2,7 @@ DOMAIN = "blink" DEVICE_ID = "Home Assistant" +CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index a42763e5843..ca3f1f6efee 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -2,7 +2,7 @@ "domain": "blink", "name": "Blink", "documentation": "https://www.home-assistant.io/integrations/blink", - "requirements": ["blinkpy==0.15.1"], + "requirements": ["blinkpy==0.16.3"], "codeowners": ["@fronzbot"], "config_flow": true } diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index e3bbe4006f3..db9bdf96273 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -11,11 +11,13 @@ "2fa": { "title": "Two-factor authentication", "data": { "2fa": "Two-factor code" }, - "description": "Enter the pin sent to your email. If the email does not contain a pin, leave blank" + "description": "Enter the pin sent to your email" } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 013b061c08e..a0ed6545b91 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -1,17 +1,24 @@ """The Bond integration.""" import asyncio +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging -from bond import Bond +from aiohttp import ClientError, ClientTimeout +from bond_api import Bond from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import SLOW_UPDATE_WARNING from .const import DOMAIN from .utils import BondHub +_LOGGER = logging.getLogger(__name__) PLATFORMS = ["cover", "fan", "light", "switch"] +_API_TIMEOUT = SLOW_UPDATE_WARNING - 1 async def async_setup(hass: HomeAssistant, config: dict): @@ -25,11 +32,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): host = entry.data[CONF_HOST] token = entry.data[CONF_ACCESS_TOKEN] - bond = Bond(bondIp=host, bondToken=token) + bond = Bond(host=host, token=token, timeout=ClientTimeout(total=_API_TIMEOUT)) hub = BondHub(bond) - await hass.async_add_executor_job(hub.setup) + try: + await hub.setup() + except (ClientError, AsyncIOTimeoutError, OSError) as error: + raise ConfigEntryNotReady from error + hass.data[DOMAIN][entry.entry_id] = hub + if not entry.unique_id: + hass.config_entries.async_update_entry(entry, unique_id=hub.bond_id) + device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index b2f009af44f..aa22dc628da 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,42 +1,45 @@ """Config flow for Bond integration.""" -from json import JSONDecodeError import logging +from typing import Any, Dict, Optional -from bond import Bond -from requests.exceptions import ConnectionError as RequestConnectionError +from aiohttp import ClientConnectionError, ClientResponseError +from bond_api import Bond import voluptuous as vol -from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME +from .const import CONF_BOND_ID from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( +DATA_SCHEMA_USER = vol.Schema( {vol.Required(CONF_HOST): str, vol.Required(CONF_ACCESS_TOKEN): str} ) +DATA_SCHEMA_DISCOVERY = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) -async def validate_input(hass: core.HomeAssistant, data): +async def _validate_input(data: Dict[str, Any]) -> str: """Validate the user input allows us to connect.""" - def authenticate(bond_hub: Bond) -> bool: - try: - bond_hub.getDeviceIds() - return True - except RequestConnectionError: - raise CannotConnect - except JSONDecodeError: - return False + try: + bond = Bond(data[CONF_HOST], data[CONF_ACCESS_TOKEN]) + version = await bond.version() + # call to non-version API is needed to validate authentication + await bond.devices() + except ClientConnectionError: + raise InputValidationError("cannot_connect") + except ClientResponseError as error: + if error.status == 401: + raise InputValidationError("invalid_auth") + raise InputValidationError("unknown") + except Exception: + _LOGGER.exception("Unexpected exception") + raise InputValidationError("unknown") - bond = Bond(data[CONF_HOST], data[CONF_ACCESS_TOKEN]) - - if not await hass.async_add_executor_job(authenticate, bond): - raise InvalidAuth - - # Return info that you want to store in the config entry. - return {"title": data[CONF_HOST]} + # Return unique ID from the hub to be stored in the config entry. + return version["bondid"] class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -45,30 +48,73 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - async def async_step_user(self, user_input=None): - """Handle the initial step.""" + _discovered: dict = None + + async def async_step_zeroconf( + self, discovery_info: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by zeroconf discovery.""" + name: str = discovery_info[CONF_NAME] + host: str = discovery_info[CONF_HOST] + bond_id = name.partition(".")[0] + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured({CONF_HOST: host}) + + self._discovered = { + CONF_HOST: host, + CONF_BOND_ID: bond_id, + } + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update({"title_placeholders": self._discovered}) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Handle confirmation flow for discovered bond hub.""" + errors = {} + if user_input is not None: + data = user_input.copy() + data[CONF_HOST] = self._discovered[CONF_HOST] + try: + return await self._try_create_entry(data) + except InputValidationError as error: + errors["base"] = error.base + + return self.async_show_form( + step_id="confirm", + data_schema=DATA_SCHEMA_DISCOVERY, + errors=errors, + description_placeholders=self._discovered, + ) + + async def async_step_user( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by the user.""" errors = {} if user_input is not None: try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return self.async_create_entry(title=info["title"], data=user_input) + return await self._try_create_entry(user_input) + except InputValidationError as error: + errors["base"] = error.base return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors ) - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" + async def _try_create_entry(self, data: Dict[str, Any]) -> Dict[str, Any]: + bond_id = await _validate_input(data) + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=bond_id, data=data) -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" +class InputValidationError(exceptions.HomeAssistantError): + """Error to indicate we cannot proceed due to invalid input.""" + + def __init__(self, base: str): + """Initialize with error base.""" + super().__init__() + self.base = base diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 4ad08991b31..843c3f9f1dc 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -1,3 +1,5 @@ """Constants for the Bond integration.""" DOMAIN = "bond" + +CONF_BOND_ID: str = "bond_id" diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index 79ccfa9210e..dc0fc6d500c 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -1,7 +1,7 @@ """Support for Bond covers.""" from typing import Any, Callable, List, Optional -from bond import DeviceTypes +from bond_api import Action, DeviceType from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -21,12 +21,10 @@ async def async_setup_entry( """Set up Bond cover devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(hub.get_bond_devices) - covers = [ BondCover(hub, device) - for device in devices - if device.type == DeviceTypes.MOTORIZED_SHADES + for device in hub.devices + if device.type == DeviceType.MOTORIZED_SHADES ] async_add_entities(covers, True) @@ -41,30 +39,28 @@ class BondCover(BondEntity, CoverEntity): self._closed: Optional[bool] = None + def _apply_state(self, state: dict): + cover_open = state.get("open") + self._closed = True if cover_open == 0 else False if cover_open == 1 else None + @property def device_class(self) -> Optional[str]: """Get device class.""" return DEVICE_CLASS_SHADE - def update(self): - """Fetch assumed state of the cover from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) - cover_open = state.get("open") - self._closed = True if cover_open == 0 else False if cover_open == 1 else None - @property def is_closed(self): """Return if the cover is closed or not.""" return self._closed - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._hub.bond.open(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.open()) - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - self._hub.bond.close(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.close()) - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Hold cover.""" - self._hub.bond.hold(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.hold()) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 0916297c074..716f121f1a1 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -1,24 +1,35 @@ """An abstract class common to all Bond entities.""" +from abc import abstractmethod +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging from typing import Any, Dict, Optional +from aiohttp import ClientError + from homeassistant.const import ATTR_NAME +from homeassistant.helpers.entity import Entity from .const import DOMAIN from .utils import BondDevice, BondHub +_LOGGER = logging.getLogger(__name__) -class BondEntity: + +class BondEntity(Entity): """Generic Bond entity encapsulating common features of any Bond controlled device.""" def __init__(self, hub: BondHub, device: BondDevice): """Initialize entity with API and device info.""" self._hub = hub self._device = device + self._available = True @property def unique_id(self) -> Optional[str]: """Get unique ID for the entity.""" - return self._device.device_id + hub_id = self._hub.bond_id + device_id = self._device.device_id + return f"{hub_id}_{device_id}" @property def name(self) -> Optional[str]: @@ -37,4 +48,30 @@ class BondEntity: @property def assumed_state(self) -> bool: """Let HA know this entity relies on an assumed state tracked by Bond.""" - return True + return self._hub.is_bridge and not self._device.trust_state + + @property + def available(self) -> bool: + """Report availability of this entity based on last API call results.""" + return self._available + + async def async_update(self): + """Fetch assumed state of the cover from the hub using API.""" + try: + state: dict = await self._hub.bond.device_state(self._device.device_id) + except (ClientError, AsyncIOTimeoutError, OSError) as error: + if self._available: + _LOGGER.warning( + "Entity %s has become unavailable", self.entity_id, exc_info=error + ) + self._available = False + else: + _LOGGER.debug("Device state for %s is:\n%s", self.entity_id, state) + if not self._available: + _LOGGER.info("Entity %s has come back", self.entity_id) + self._available = True + self._apply_state(state) + + @abstractmethod + def _apply_state(self, state: dict): + raise NotImplementedError diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 0d7013b4ccf..cb247b37309 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -2,7 +2,7 @@ import math from typing import Any, Callable, List, Optional -from bond import DeviceTypes, Directions +from bond_api import Action, DeviceType, Direction from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -32,12 +32,8 @@ async def async_setup_entry( """Set up Bond fan devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(hub.get_bond_devices) - fans = [ - BondFan(hub, device) - for device in devices - if device.type == DeviceTypes.CEILING_FAN + BondFan(hub, device) for device in hub.devices if DeviceType.is_fan(device.type) ] async_add_entities(fans, True) @@ -54,6 +50,11 @@ class BondFan(BondEntity, FanEntity): self._speed: Optional[int] = None self._direction: Optional[int] = None + def _apply_state(self, state: dict): + self._power = state.get("power") + self._speed = state.get("speed") + self._direction = state.get("direction") + @property def supported_features(self) -> int: """Flag supported features.""" @@ -74,7 +75,7 @@ class BondFan(BondEntity, FanEntity): return None # map 1..max_speed Bond speed to 1..3 HA speed - max_speed = self._device.props.get("max_speed", 3) + max_speed = max(self._device.props.get("max_speed", 3), self._speed) ha_speed = math.ceil(self._speed * (len(self.speed_list) - 1) / max_speed) return self.speed_list[ha_speed] @@ -87,21 +88,14 @@ class BondFan(BondEntity, FanEntity): def current_direction(self) -> Optional[str]: """Return fan rotation direction.""" direction = None - if self._direction == Directions.FORWARD: + if self._direction == Direction.FORWARD: direction = DIRECTION_FORWARD - elif self._direction == Directions.REVERSE: + elif self._direction == Direction.REVERSE: direction = DIRECTION_REVERSE return direction - def update(self): - """Fetch assumed state of the fan from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) - self._power = state.get("power") - self._speed = state.get("speed") - self._direction = state.get("direction") - - def set_speed(self, speed: str) -> None: + async def async_set_speed(self, speed: str) -> None: """Set the desired speed for the fan.""" max_speed = self._device.props.get("max_speed", 3) if speed == SPEED_LOW: @@ -110,21 +104,27 @@ class BondFan(BondEntity, FanEntity): bond_speed = max_speed else: bond_speed = math.ceil(max_speed / 2) - self._hub.bond.setSpeed(self._device.device_id, speed=bond_speed) - def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + await self._hub.bond.action( + self._device.device_id, Action.set_speed(bond_speed) + ) + + async def async_turn_on(self, speed: Optional[str] = None, **kwargs) -> None: """Turn on the fan.""" if speed is not None: - self.set_speed(speed) - self._hub.bond.turnOn(self._device.device_id) + await self.async_set_speed(speed) + else: + await self._hub.bond.action(self._device.device_id, Action.turn_on()) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - self._hub.bond.turnOff(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_off()) - def set_direction(self, direction: str) -> None: + async def async_set_direction(self, direction: str): """Set fan rotation direction.""" bond_direction = ( - Directions.REVERSE if direction == DIRECTION_REVERSE else Directions.FORWARD + Direction.REVERSE if direction == DIRECTION_REVERSE else Direction.FORWARD + ) + await self._hub.bond.action( + self._device.device_id, Action.set_direction(bond_direction) ) - self._hub.bond.setDirection(self._device.device_id, bond_direction) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 949c5a54070..7dec44dbb38 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -1,7 +1,7 @@ """Support for Bond lights.""" from typing import Any, Callable, List, Optional -from bond import DeviceTypes +from bond_api import Action, DeviceType from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -26,21 +26,19 @@ async def async_setup_entry( """Set up Bond light devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(hub.get_bond_devices) - - lights = [ + lights: List[Entity] = [ BondLight(hub, device) - for device in devices - if device.type == DeviceTypes.CEILING_FAN and device.supports_light() + for device in hub.devices + if DeviceType.is_fan(device.type) and device.supports_light() ] - async_add_entities(lights, True) - fireplaces = [ + fireplaces: List[Entity] = [ BondFireplace(hub, device) - for device in devices - if device.type == DeviceTypes.FIREPLACE + for device in hub.devices + if DeviceType.is_fireplace(device.type) ] - async_add_entities(fireplaces, True) + + async_add_entities(lights + fireplaces, True) class BondLight(BondEntity, LightEntity): @@ -49,26 +47,49 @@ class BondLight(BondEntity, LightEntity): def __init__(self, hub: BondHub, device: BondDevice): """Create HA entity representing Bond fan.""" super().__init__(hub, device) - + self._brightness: Optional[int] = None self._light: Optional[int] = None + def _apply_state(self, state: dict): + self._light = state.get("light") + self._brightness = state.get("brightness") + + @property + def supported_features(self) -> Optional[int]: + """Flag supported features.""" + features = 0 + if self._device.supports_set_brightness(): + features |= SUPPORT_BRIGHTNESS + + return features + @property def is_on(self) -> bool: """Return if light is currently on.""" return self._light == 1 - def update(self): - """Fetch assumed state of the light from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) - self._light = state.get("light") + @property + def brightness(self) -> int: + """Return the brightness of this light between 1..255.""" + brightness_value = ( + round(self._brightness * 255 / 100) if self._brightness else None + ) + return brightness_value - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - self._hub.bond.turnLightOn(self._device.device_id) + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness: + await self._hub.bond.action( + self._device.device_id, + Action.set_brightness(round((brightness * 100) / 255)), + ) + else: + await self._hub.bond.action(self._device.device_id, Action.turn_light_on()) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - self._hub.bond.turnLightOff(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_light_off()) class BondFireplace(BondEntity, LightEntity): @@ -82,6 +103,10 @@ class BondFireplace(BondEntity, LightEntity): # Bond flame level, 0-100 self._flame: Optional[int] = None + def _apply_state(self, state: dict): + self._power = state.get("power") + self._flame = state.get("flame") + @property def supported_features(self) -> Optional[int]: """Flag brightness as supported feature to represent flame level.""" @@ -92,18 +117,18 @@ class BondFireplace(BondEntity, LightEntity): """Return True if power is on.""" return self._power == 1 - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the fireplace on.""" - self._hub.bond.turnOn(self._device.device_id) - brightness = kwargs.get(ATTR_BRIGHTNESS) if brightness: flame = round((brightness * 100) / 255) - self._hub.bond.setFlame(self._device.device_id, flame) + await self._hub.bond.action(self._device.device_id, Action.set_flame(flame)) + else: + await self._hub.bond.action(self._device.device_id, Action.turn_on()) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fireplace off.""" - self._hub.bond.turnOff(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_off()) @property def brightness(self): @@ -114,9 +139,3 @@ class BondFireplace(BondEntity, LightEntity): def icon(self) -> Optional[str]: """Show fireplace icon for the entity.""" return "mdi:fireplace" if self._power == 1 else "mdi:fireplace-off" - - def update(self): - """Fetch assumed state of the device from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) - self._power = state.get("power") - self._flame = state.get("flame") diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index b9e57981400..3f62403dba7 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,10 +3,8 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": [ - "bond-home==0.0.9" - ], - "codeowners": [ - "@prystupa" - ] + "requirements": ["bond-api==0.1.8"], + "zeroconf": ["_bond._tcp.local."], + "codeowners": ["@prystupa"], + "quality_scale": "platinum" } diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index a243c938f12..ba59a61d58d 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -1,6 +1,13 @@ { "config": { + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "description": "Do you want to set up {bond_id}?", + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -12,6 +19,9 @@ "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/bond/switch.py b/homeassistant/components/bond/switch.py index e7892272bbf..d2f1797225d 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -1,13 +1,13 @@ """Support for Bond generic devices.""" from typing import Any, Callable, List, Optional -from bond import DeviceTypes +from bond_api import Action, DeviceType +from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from ..switch import SwitchEntity from .const import DOMAIN from .entity import BondEntity from .utils import BondDevice, BondHub @@ -21,12 +21,10 @@ async def async_setup_entry( """Set up Bond generic devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(hub.get_bond_devices) - switches = [ BondSwitch(hub, device) - for device in devices - if device.type == DeviceTypes.GENERIC_DEVICE + for device in hub.devices + if DeviceType.is_generic(device.type) ] async_add_entities(switches, True) @@ -41,20 +39,18 @@ class BondSwitch(BondEntity, SwitchEntity): self._power: Optional[bool] = None + def _apply_state(self, state: dict): + self._power = state.get("power") + @property def is_on(self) -> bool: """Return True if power is on.""" return self._power == 1 - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - self._hub.bond.turnOn(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_on()) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self._hub.bond.turnOff(self._device.device_id) - - def update(self): - """Fetch assumed state of the device from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) - self._power = state.get("power") + await self._hub.bond.action(self._device.device_id, Action.turn_off()) diff --git a/homeassistant/components/bond/translations/ca.json b/homeassistant/components/bond/translations/ca.json index f3a39bde721..b068144df09 100644 --- a/homeassistant/components/bond/translations/ca.json +++ b/homeassistant/components/bond/translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", diff --git a/homeassistant/components/bond/translations/cs.json b/homeassistant/components/bond/translations/cs.json new file mode 100644 index 00000000000..bf42fe8d5fc --- /dev/null +++ b/homeassistant/components/bond/translations/cs.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nakofigurovan\u00e9" + }, + "flow_title": "Bond: {bond_id} ({host})", + "step": { + "confirm": { + "data": { + "access_token": "P\u0159\u00edstupov\u00fd token" + }, + "description": "Chcete nastavit {bond_id} ?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/en.json b/homeassistant/components/bond/translations/en.json index da96c12c92c..2e636bb8999 100644 --- a/homeassistant/components/bond/translations/en.json +++ b/homeassistant/components/bond/translations/en.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "already_configured": "Device is already configured" + }, "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" }, + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "Access Token" + }, + "description": "Do you want to set up {bond_id}?" + }, "user": { "data": { "access_token": "Access Token", diff --git a/homeassistant/components/bond/translations/es.json b/homeassistant/components/bond/translations/es.json index 9620672ccf0..063915421e3 100644 --- a/homeassistant/components/bond/translations/es.json +++ b/homeassistant/components/bond/translations/es.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { "cannot_connect": "No se pudo conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "Token de acceso" + }, + "description": "\u00bfQuieres configurar {bond_id}?" + }, "user": { "data": { "access_token": "Token de acceso", diff --git a/homeassistant/components/bond/translations/it.json b/homeassistant/components/bond/translations/it.json index 3f69402f705..5b1434e9b63 100644 --- a/homeassistant/components/bond/translations/it.json +++ b/homeassistant/components/bond/translations/it.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, "error": { "cannot_connect": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida", "unknown": "Errore imprevisto" }, + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "Token di accesso" + }, + "description": "Vuoi configurare {bond_id}?" + }, "user": { "data": { "access_token": "Token di accesso", diff --git a/homeassistant/components/bond/translations/lb.json b/homeassistant/components/bond/translations/lb.json new file mode 100644 index 00000000000..abce9d5efb9 --- /dev/null +++ b/homeassistant/components/bond/translations/lb.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Feeler beim verbannen", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "access_token": "Acc\u00e8s jeton", + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/no.json b/homeassistant/components/bond/translations/no.json new file mode 100644 index 00000000000..1a1c8792dc0 --- /dev/null +++ b/homeassistant/components/bond/translations/no.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "flow_title": "Obligasjon: {bond_id} ( {host} )", + "step": { + "confirm": { + "data": { + "access_token": "Tilgangstoken" + }, + "description": "Vil du konfigurere {bond_id}?" + }, + "user": { + "data": { + "access_token": "Tilgangstoken", + "host": "Vert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/pl.json b/homeassistant/components/bond/translations/pl.json new file mode 100644 index 00000000000..10b6433daee --- /dev/null +++ b/homeassistant/components/bond/translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "access_token": "Token dost\u0119pu", + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/ru.json b/homeassistant/components/bond/translations/ru.json index bd57c6c7095..566b04e1af8 100644 --- a/homeassistant/components/bond/translations/ru.json +++ b/homeassistant/components/bond/translations/ru.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, "error": { "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, + "flow_title": "Bond {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430" + }, + "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 {bond_id}?" + }, "user": { "data": { "access_token": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430", diff --git a/homeassistant/components/bond/translations/sl.json b/homeassistant/components/bond/translations/sl.json new file mode 100644 index 00000000000..63833bfccde --- /dev/null +++ b/homeassistant/components/bond/translations/sl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "access_token": "\u017deton za dostop", + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/uk.json b/homeassistant/components/bond/translations/uk.json new file mode 100644 index 00000000000..d7da60ea178 --- /dev/null +++ b/homeassistant/components/bond/translations/uk.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/zh-Hant.json b/homeassistant/components/bond/translations/zh-Hant.json index 0a4e3dc061e..915ff9b6a05 100644 --- a/homeassistant/components/bond/translations/zh-Hant.json +++ b/homeassistant/components/bond/translations/zh-Hant.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "already_configured": "\u8a2d\u5099\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" }, + "flow_title": "Bond\uff1a{bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "\u5b58\u53d6\u5bc6\u9470" + }, + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {bond_id}\uff1f" + }, "user": { "data": { "access_token": "\u5b58\u53d6\u5bc6\u9470", diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 48fbcd80210..5a9fff692fa 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -1,8 +1,10 @@ """Reusable utilities for the Bond component.""" - +import logging from typing import List, Optional -from bond import Actions, Bond +from bond_api import Action, Bond + +_LOGGER = logging.getLogger(__name__) class BondDevice: @@ -14,6 +16,14 @@ class BondDevice: self.props = props self._attrs = attrs + def __repr__(self): + """Return readable representation of a bond device.""" + return { + "device_id": self.device_id, + "props": self.props, + "attrs": self._attrs, + }.__repr__() + @property def name(self) -> str: """Get the name of this device.""" @@ -24,21 +34,20 @@ class BondDevice: """Get the type of this device.""" return self._attrs["type"] + @property + def trust_state(self) -> bool: + """Check if Trust State is turned on.""" + return self.props.get("trust_state", False) + def supports_speed(self) -> bool: """Return True if this device supports any of the speed related commands.""" actions: List[str] = self._attrs["actions"] - return bool([action for action in actions if action in [Actions.SET_SPEED]]) + return bool([action for action in actions if action in [Action.SET_SPEED]]) def supports_direction(self) -> bool: """Return True if this device supports any of the direction related commands.""" actions: List[str] = self._attrs["actions"] - return bool( - [ - action - for action in actions - if action in [Actions.SET_DIRECTION, Actions.TOGGLE_DIRECTION] - ] - ) + return bool([action for action in actions if action in [Action.SET_DIRECTION]]) def supports_light(self) -> bool: """Return True if this device supports any of the light related commands.""" @@ -47,10 +56,15 @@ class BondDevice: [ action for action in actions - if action in [Actions.TURN_LIGHT_ON, Actions.TOGGLE_LIGHT] + if action in [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF] ] ) + def supports_set_brightness(self) -> bool: + """Return True if this device supports setting a light brightness.""" + actions: List[str] = self._attrs["actions"] + return bool([action for action in actions if action in [Action.SET_BRIGHTNESS]]) + class BondHub: """Hub device representing Bond Bridge.""" @@ -59,23 +73,25 @@ class BondHub: """Initialize Bond Hub.""" self.bond: Bond = bond self._version: Optional[dict] = None + self._devices: Optional[List[BondDevice]] = None - def setup(self): + async def setup(self): """Read hub version information.""" - self._version = self.bond.getVersion() + self._version = await self.bond.version() + _LOGGER.debug("Bond reported the following version info: %s", self._version) - def get_bond_devices(self) -> List[BondDevice]: - """Fetch all available devices using Bond API.""" - device_ids = self.bond.getDeviceIds() - devices = [ + # Fetch all available devices using Bond API. + device_ids = await self.bond.devices() + self._devices = [ BondDevice( device_id, - self.bond.getDevice(device_id), - self.bond.getProperties(device_id), + await self.bond.device(device_id), + await self.bond.device_properties(device_id), ) for device_id in device_ids ] - return devices + + _LOGGER.debug("Discovered Bond devices: %s", self._devices) @property def bond_id(self) -> str: @@ -91,3 +107,14 @@ class BondHub: def fw_ver(self) -> str: """Return this hub firmware version.""" return self._version.get("fw_ver") + + @property + def devices(self) -> List[BondDevice]: + """Return a list of all devices controlled by this hub.""" + return self._devices + + @property + def is_bridge(self) -> bool: + """Return if the Bond is a Bond Bridge.""" + # If False, it means that it is a Smart by Bond product. Assumes that it is if the model is not available. + return self._version.get("model", "BD-").startswith("BD-") diff --git a/homeassistant/components/braviatv/translations/es.json b/homeassistant/components/braviatv/translations/es.json index 61dac5c9d62..8995161f5de 100644 --- a/homeassistant/components/braviatv/translations/es.json +++ b/homeassistant/components/braviatv/translations/es.json @@ -19,7 +19,7 @@ }, "user": { "data": { - "host": "Nombre host del televisor o direcci\u00f3n IP" + "host": "Host" }, "description": "Configura la integraci\u00f3n del televisor Sony Bravia. Si tienes problemas con la configuraci\u00f3n, ve a: https://www.home-assistant.io/integrations/braviatv\n\nAseg\u00farate de que tu televisor est\u00e1 encendido.", "title": "Televisor Sony Bravia" diff --git a/homeassistant/components/brother/translations/es.json b/homeassistant/components/brother/translations/es.json index af05a39d9c9..51e1492be13 100644 --- a/homeassistant/components/brother/translations/es.json +++ b/homeassistant/components/brother/translations/es.json @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Nombre del host o direcci\u00f3n IP de la impresora", + "host": "Host", "type": "Tipo de impresora" }, "description": "Configura la integraci\u00f3n de impresoras Brother. Si tienes problemas con la configuraci\u00f3n, ve a: https://www.home-assistant.io/integrations/brother" diff --git a/homeassistant/components/brother/translations/tr.json b/homeassistant/components/brother/translations/tr.json new file mode 100644 index 00000000000..160a5ecc7b7 --- /dev/null +++ b/homeassistant/components/brother/translations/tr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "zeroconf_confirm": { + "title": "Ke\u015ffedilen Brother Yaz\u0131c\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/es.json b/homeassistant/components/bsblan/translations/es.json index 9d90f95e3d8..e22dbfa75ec 100644 --- a/homeassistant/components/bsblan/translations/es.json +++ b/homeassistant/components/bsblan/translations/es.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Host o direcci\u00f3n IP", + "host": "Host", "passkey": "Clave de acceso", "port": "Puerto" }, diff --git a/homeassistant/components/linky/translations/nn.json b/homeassistant/components/bsblan/translations/nl.json similarity index 52% rename from homeassistant/components/linky/translations/nn.json rename to homeassistant/components/bsblan/translations/nl.json index 6cdaaf837a4..c1909b19508 100644 --- a/homeassistant/components/linky/translations/nn.json +++ b/homeassistant/components/bsblan/translations/nl.json @@ -2,7 +2,9 @@ "config": { "step": { "user": { - "title": "Linky" + "data": { + "port": "Poort" + } } } } diff --git a/homeassistant/components/bsblan/translations/no.json b/homeassistant/components/bsblan/translations/no.json index 393024d2eff..040349997f4 100644 --- a/homeassistant/components/bsblan/translations/no.json +++ b/homeassistant/components/bsblan/translations/no.json @@ -12,7 +12,7 @@ "data": { "host": "Vert", "passkey": "Tilgangsn\u00f8kkel streng", - "port": "Port" + "port": "" }, "description": "Konfigurer din BSB-Lan-enhet for \u00e5 integrere med Home Assistant.", "title": "Koble til BSB-Lan-enheten" diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 78c8f82d1ff..1bc0ee464db 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -95,6 +95,8 @@ class BuienradarCam(Camera): # deadline for image refresh - self.delta after last successful load self._deadline: Optional[datetime] = None + self._unique_id = f"{self._dimension}_{self._country}" + @property def name(self) -> str: """Return the component name.""" @@ -186,3 +188,8 @@ class BuienradarCam(Camera): async with self._condition: self._loading = False self._condition.notify_all() + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 92811b98a80..47bacfe2247 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -204,7 +204,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Create the buienradar sensor.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) timeframe = config[CONF_TIMEFRAME] @@ -236,7 +235,6 @@ class BrSensor(Entity): def __init__(self, sensor_type, client_name, coordinates): """Initialize the sensor.""" - self.client_name = client_name self._name = SENSOR_TYPES[sensor_type][0] self.type = sensor_type @@ -428,7 +426,6 @@ class BrSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if self.type.startswith(PRECIPITATION_FORECAST): result = {ATTR_ATTRIBUTION: self._attribution} if self._timeframe is not None: diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index e64925bf19e..b4f2314eee5 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -107,7 +107,6 @@ class BrData: async def async_update(self, *_): """Update the data from buienradar.""" - content = await self.get_data(JSON_FEED_URL) if content.get(SUCCESS) is not True: @@ -170,25 +169,21 @@ class BrData: @property def attribution(self): """Return the attribution.""" - return self.data.get(ATTRIBUTION) @property def stationname(self): """Return the name of the selected weatherstation.""" - return self.data.get(STATIONNAME) @property def condition(self): """Return the condition.""" - return self.data.get(CONDITION) @property def temperature(self): """Return the temperature, or None.""" - try: return float(self.data.get(TEMPERATURE)) except (ValueError, TypeError): @@ -197,7 +192,6 @@ class BrData: @property def pressure(self): """Return the pressure, or None.""" - try: return float(self.data.get(PRESSURE)) except (ValueError, TypeError): @@ -206,7 +200,6 @@ class BrData: @property def humidity(self): """Return the humidity, or None.""" - try: return int(self.data.get(HUMIDITY)) except (ValueError, TypeError): @@ -215,7 +208,6 @@ class BrData: @property def visibility(self): """Return the visibility, or None.""" - try: return int(self.data.get(VISIBILITY)) except (ValueError, TypeError): @@ -224,7 +216,6 @@ class BrData: @property def wind_speed(self): """Return the windspeed, or None.""" - try: return float(self.data.get(WINDSPEED)) except (ValueError, TypeError): @@ -233,7 +224,6 @@ class BrData: @property def wind_bearing(self): """Return the wind bearing, or None.""" - try: return int(self.data.get(WINDAZIMUTH)) except (ValueError, TypeError): @@ -242,5 +232,4 @@ class BrData: @property def forecast(self): """Return the forecast data.""" - return self.data.get(FORECAST) diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 37dee08313e..d0a0c0e18b4 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -90,7 +90,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= for condi in condlst: hass.data[DATA_CONDITION][condi] = cond - async_add_entities([BrWeather(data, config)]) + async_add_entities([BrWeather(data, config, coordinates)]) # schedule the first update in 1 minute from now: await data.schedule_update(1) @@ -99,12 +99,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BrWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, data, config): + def __init__(self, data, config, coordinates): """Initialise the platform with a data instance and station name.""" self._stationname = config.get(CONF_NAME) self._forecast = config[CONF_FORECAST] self._data = data + self._unique_id = "{:2.6f}{:2.6f}".format( + coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE] + ) + @property def attribution(self): """Return the attribution.""" @@ -120,7 +124,6 @@ class BrWeather(WeatherEntity): @property def condition(self): """Return the current condition.""" - if self._data and self._data.condition: ccode = self._data.condition.get(CONDCODE) if ccode: @@ -170,7 +173,6 @@ class BrWeather(WeatherEntity): @property def forecast(self): """Return the forecast array.""" - if not self._forecast: return None @@ -197,3 +199,8 @@ class BrWeather(WeatherEntity): fcdata_out.append(data_out) return fcdata_out + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 14f94976984..70d33da884c 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -36,7 +36,7 @@ snapshot: example: "camera.living_room_camera" filename: description: Template of a Filename. Variable is entity_id. - example: "/tmp/snapshot_{{ entity_id }}" + example: "/tmp/snapshot_{{ entity_id.name }}.jpg" play_stream: description: Play camera stream on supported media player. @@ -59,7 +59,7 @@ record: example: "camera.living_room_camera" filename: description: Template of a Filename. Variable is entity_id. Must be mp4. - example: "/tmp/snapshot_{{ entity_id }}.mp4" + example: "/tmp/snapshot_{{ entity_id.name }}.mp4" duration: description: (Optional) Target recording length (in seconds). default: 30 diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 38c73f8df2b..19fa4927d05 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1,6 +1,7 @@ """The cert_expiry component.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging +from typing import Optional from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -50,7 +51,7 @@ async def async_unload_entry(hass, entry): return await hass.config_entries.async_forward_entry_unload(entry, "sensor") -class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator): +class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime]): """Class to manage fetching Cert Expiry data from single endpoint.""" def __init__(self, hass, host, port): @@ -67,7 +68,7 @@ class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, name=name, update_interval=SCAN_INTERVAL, ) - async def _async_update_data(self): + async def _async_update_data(self) -> Optional[datetime]: """Fetch certificate.""" try: timestamp = await get_cert_expiry_timestamp(self.hass, self.host, self.port) diff --git a/homeassistant/components/cert_expiry/translations/es.json b/homeassistant/components/cert_expiry/translations/es.json index d616634fdea..8f62d763063 100644 --- a/homeassistant/components/cert_expiry/translations/es.json +++ b/homeassistant/components/cert_expiry/translations/es.json @@ -12,9 +12,9 @@ "step": { "user": { "data": { - "host": "El nombre de host del certificado", + "host": "Host", "name": "El nombre del certificado", - "port": "El puerto del certificado" + "port": "Puerto" }, "title": "Defina el certificado para probar" } diff --git a/homeassistant/components/cert_expiry/translations/no.json b/homeassistant/components/cert_expiry/translations/no.json index 3786900bbbd..a7aa3d1ab13 100644 --- a/homeassistant/components/cert_expiry/translations/no.json +++ b/homeassistant/components/cert_expiry/translations/no.json @@ -14,7 +14,7 @@ "data": { "host": "Vert", "name": "Sertifikatets navn", - "port": "Port" + "port": "" }, "title": "Definer sertifikatet som skal testes" } diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index 0c1ce2e9585..966dbdee6e2 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -37,5 +37,5 @@ class ClickatellNotificationService(BaseNotificationService): data = {"apiKey": self.api_key, "to": self.recipient, "content": message} resp = requests.get(BASE_API_URL, params=data, timeout=5) - if (resp.status_code != HTTP_OK) or (resp.status_code != 201): + if (resp.status_code != HTTP_OK) or (resp.status_code != 202): _LOGGER.error("Error %s : %s", resp.status_code, resp.text) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 8d58e98c0e5..e4ca94d6b38 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.34.7"], + "requirements": ["hass-nabucasa==0.35.0"], "dependencies": ["http", "webhook", "alexa"], "after_dependencies": ["google_assistant"], "codeowners": ["@home-assistant/cloud"] diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index fe0640d3efa..4f98818d9b3 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1 +1,47 @@ """The command_line component.""" + +import logging +import subprocess + +_LOGGER = logging.getLogger(__name__) + + +def call_shell_with_timeout(command, timeout, *, log_return_code=True): + """Run a shell command with a timeout. + + If log_return_code is set to False, it will not print an error if a non-zero + return code is returned. + """ + try: + _LOGGER.debug("Running command: %s", command) + subprocess.check_output( + command, shell=True, timeout=timeout # nosec # shell by design + ) + return 0 + except subprocess.CalledProcessError as proc_exception: + if log_return_code: + _LOGGER.error("Command failed: %s", command) + return proc_exception.returncode + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + return -1 + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + return -1 + + +def check_output_or_log(command, timeout): + """Run a shell command with a timeout and return the output.""" + try: + return_value = subprocess.check_output( + command, shell=True, timeout=timeout # nosec # shell by design + ) + return return_value.strip().decode("utf-8") + except subprocess.CalledProcessError: + _LOGGER.error("Command failed: %s", command) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + + return None diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index dc62d8daa9d..86916e86a26 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT from .sensor import CommandSensorData _LOGGER = logging.getLogger(__name__) @@ -29,8 +30,6 @@ DEFAULT_PAYLOAD_OFF = "OFF" SCAN_INTERVAL = timedelta(seconds=60) -CONF_COMMAND_TIMEOUT = "command_timeout" -DEFAULT_TIMEOUT = 15 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/command_line/const.py b/homeassistant/components/command_line/const.py new file mode 100644 index 00000000000..8c5bc0b2967 --- /dev/null +++ b/homeassistant/components/command_line/const.py @@ -0,0 +1,4 @@ +"""Allows to configure custom shell commands to turn a value for a sensor.""" + +CONF_COMMAND_TIMEOUT = "command_timeout" +DEFAULT_TIMEOUT = 15 diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 6f2a038d051..1fdcdf3b3e7 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -1,6 +1,5 @@ """Support for command line covers.""" import logging -import subprocess import voluptuous as vol @@ -16,6 +15,9 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv +from . import call_shell_with_timeout, check_output_or_log +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) COVER_SCHEMA = vol.Schema( @@ -26,6 +28,7 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, } ) @@ -48,11 +51,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): CommandCover( hass, device_config.get(CONF_FRIENDLY_NAME, device_name), - device_config.get(CONF_COMMAND_OPEN), - device_config.get(CONF_COMMAND_CLOSE), - device_config.get(CONF_COMMAND_STOP), + device_config[CONF_COMMAND_OPEN], + device_config[CONF_COMMAND_CLOSE], + device_config[CONF_COMMAND_STOP], device_config.get(CONF_COMMAND_STATE), value_template, + device_config[CONF_COMMAND_TIMEOUT], ) ) @@ -75,6 +79,7 @@ class CommandCover(CoverEntity): command_stop, command_state, value_template, + timeout, ): """Initialize the cover.""" self._hass = hass @@ -85,31 +90,23 @@ class CommandCover(CoverEntity): self._command_stop = command_stop self._command_state = command_state self._value_template = value_template + self._timeout = timeout - @staticmethod - def _move_cover(command): + def _move_cover(self, command): """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = subprocess.call(command, shell=True) == 0 # nosec # shell by design + success = call_shell_with_timeout(command, self._timeout) == 0 if not success: _LOGGER.error("Command failed: %s", command) return success - @staticmethod - def _query_state_value(command): + def _query_state_value(self, command): """Execute state command for return value.""" - _LOGGER.info("Running state command: %s", command) - - try: - return_value = subprocess.check_output( - command, shell=True # nosec # shell by design - ) - return return_value.strip().decode("utf-8") - except subprocess.CalledProcessError: - _LOGGER.error("Command failed: %s", command) + _LOGGER.info("Running state value command: %s", command) + return check_output_or_log(command, self._timeout) @property def should_poll(self): diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 50b0bec74ee..948bda7e45a 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -8,26 +8,34 @@ from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationSer from homeassistant.const import CONF_COMMAND, CONF_NAME import homeassistant.helpers.config_validation as cv +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COMMAND): cv.string, vol.Optional(CONF_NAME): cv.string} + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + } ) def get_service(hass, config, discovery_info=None): """Get the Command Line notification service.""" command = config[CONF_COMMAND] + timeout = config[CONF_COMMAND_TIMEOUT] - return CommandLineNotificationService(command) + return CommandLineNotificationService(command, timeout) class CommandLineNotificationService(BaseNotificationService): """Implement the notification service for the Command Line service.""" - def __init__(self, command): + def __init__(self, command, timeout): """Initialize the service.""" self.command = command + self._timeout = timeout def send_message(self, message="", **kwargs): """Send a message to a command line.""" @@ -38,8 +46,10 @@ class CommandLineNotificationService(BaseNotificationService): stdin=subprocess.PIPE, shell=True, # nosec # shell by design ) - proc.communicate(input=message) + proc.communicate(input=message, timeout=self._timeout) if proc.returncode != 0: _LOGGER.error("Command failed: %s", self.command) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", self.command) except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec Command: %s", self.command) + _LOGGER.error("Error trying to exec command: %s", self.command) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index feb63e18443..778806099aa 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -3,7 +3,6 @@ from collections.abc import Mapping from datetime import timedelta import json import logging -import subprocess import voluptuous as vol @@ -20,13 +19,14 @@ from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from . import check_output_or_log +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) -CONF_COMMAND_TIMEOUT = "command_timeout" CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" -DEFAULT_TIMEOUT = 15 SCAN_INTERVAL = timedelta(seconds=60) @@ -171,13 +171,6 @@ class CommandSensorData: else: # Template used. Construct the string used in the shell command = f"{prog} {rendered_args}" - try: - _LOGGER.debug("Running command: %s", command) - return_value = subprocess.check_output( - command, shell=True, timeout=self.timeout # nosec # shell by design - ) - self.value = return_value.strip().decode("utf-8") - except subprocess.CalledProcessError: - _LOGGER.error("Command failed: %s", command) - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) + + _LOGGER.debug("Running command: %s", command) + self.value = check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7f62970b639..804e3c6a4d5 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -1,6 +1,5 @@ """Support for custom shell commands to turn a switch on/off.""" import logging -import subprocess import voluptuous as vol @@ -19,6 +18,9 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv +from . import call_shell_with_timeout, check_output_or_log +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) SWITCH_SCHEMA = vol.Schema( @@ -28,6 +30,7 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, } ) @@ -52,10 +55,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hass, object_id, device_config.get(CONF_FRIENDLY_NAME, object_id), - device_config.get(CONF_COMMAND_ON), - device_config.get(CONF_COMMAND_OFF), + device_config[CONF_COMMAND_ON], + device_config[CONF_COMMAND_OFF], device_config.get(CONF_COMMAND_STATE), value_template, + device_config[CONF_COMMAND_TIMEOUT], ) ) @@ -78,6 +82,7 @@ class CommandSwitch(SwitchEntity): command_off, command_state, value_template, + timeout, ): """Initialize the switch.""" self._hass = hass @@ -88,37 +93,30 @@ class CommandSwitch(SwitchEntity): self._command_off = command_off self._command_state = command_state self._value_template = value_template + self._timeout = timeout - @staticmethod - def _switch(command): + def _switch(self, command): """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = subprocess.call(command, shell=True) == 0 # nosec # shell by design + success = call_shell_with_timeout(command, self._timeout) == 0 if not success: _LOGGER.error("Command failed: %s", command) return success - @staticmethod - def _query_state_value(command): + def _query_state_value(self, command): """Execute state command for return value.""" - _LOGGER.info("Running state command: %s", command) + _LOGGER.info("Running state value command: %s", command) + return check_output_or_log(command, self._timeout) - try: - return_value = subprocess.check_output( - command, shell=True # nosec # shell by design - ) - return return_value.strip().decode("utf-8") - except subprocess.CalledProcessError: - _LOGGER.error("Command failed: %s", command) - - @staticmethod - def _query_state_code(command): + def _query_state_code(self, command): """Execute state command for return code.""" - _LOGGER.info("Running state command: %s", command) - return subprocess.call(command, shell=True) == 0 # nosec # shell by design + _LOGGER.info("Running state code command: %s", command) + return ( + call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0 + ) @property def should_poll(self): @@ -146,8 +144,8 @@ class CommandSwitch(SwitchEntity): _LOGGER.error("No state command specified") return if self._value_template: - return CommandSwitch._query_state_value(self._command_state) - return CommandSwitch._query_state_code(self._command_state) + return self._query_state_value(self._command_state) + return self._query_state_code(self._command_state) def update(self): """Update device state.""" @@ -159,12 +157,12 @@ class CommandSwitch(SwitchEntity): def turn_on(self, **kwargs): """Turn the device on.""" - if CommandSwitch._switch(self._command_on) and not self._command_state: + if self._switch(self._command_on) and not self._command_state: self._state = True self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" - if CommandSwitch._switch(self._command_off) and not self._command_state: + if self._switch(self._command_off) and not self._command_state: self._state = False self.schedule_update_ha_state() diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 5b12ccb92eb..de1f38f3e57 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -73,6 +73,7 @@ def _entry_dict(entry): "sw_version": entry.sw_version, "entry_type": entry.entry_type, "id": entry.id, + "identifiers": list(entry.identifiers), "via_device_id": entry.via_device_id, "area_id": entry.area_id, "name_by_user": entry.name_by_user, diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py new file mode 100644 index 00000000000..0f27c678e59 --- /dev/null +++ b/homeassistant/components/control4/__init__.py @@ -0,0 +1,224 @@ +"""The Control4 integration.""" +import asyncio +import json +import logging + +from aiohttp import client_exceptions +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import BadCredentials + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, device_registry as dr, entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_ACCOUNT, + CONF_CONFIG_LISTENER, + CONF_CONTROLLER_UNIQUE_ID, + CONF_DIRECTOR, + CONF_DIRECTOR_ALL_ITEMS, + CONF_DIRECTOR_MODEL, + CONF_DIRECTOR_SW_VERSION, + CONF_DIRECTOR_TOKEN_EXPIRATION, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["light"] + + +async def async_setup(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Stub to allow setting up this component. + + Configuration through YAML is not supported at this time. + """ + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Control4 from a config entry.""" + entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) + account_session = aiohttp_client.async_get_clientsession(hass) + + config = entry.data + account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) + try: + await account.getAccountBearerToken() + except client_exceptions.ClientError as exception: + _LOGGER.error("Error connecting to Control4 account API: %s", exception) + raise ConfigEntryNotReady + except BadCredentials as exception: + _LOGGER.error( + "Error authenticating with Control4 account API, incorrect username or password: %s", + exception, + ) + return False + entry_data[CONF_ACCOUNT] = account + + controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] + entry_data[CONF_CONTROLLER_UNIQUE_ID] = controller_unique_id + + director_token_dict = await account.getDirectorBearerToken(controller_unique_id) + director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + + director = C4Director( + config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session + ) + entry_data[CONF_DIRECTOR] = director + entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_dict["token_expiration"] + + # Add Control4 controller to device registry + controller_href = (await account.getAccountControllers())["href"] + entry_data[CONF_DIRECTOR_SW_VERSION] = await account.getControllerOSVersion( + controller_href + ) + + _, model, mac_address = controller_unique_id.split("_", 3) + entry_data[CONF_DIRECTOR_MODEL] = model.upper() + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, controller_unique_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, + manufacturer="Control4", + name=controller_unique_id, + model=entry_data[CONF_DIRECTOR_MODEL], + sw_version=entry_data[CONF_DIRECTOR_SW_VERSION], + ) + + # Store all items found on controller for platforms to use + director_all_items = await director.getAllItemInfo() + director_all_items = json.loads(director_all_items) + entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items + + # Load options from config entry + entry_data[CONF_SCAN_INTERVAL] = entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + + entry_data[CONF_CONFIG_LISTENER] = entry.add_update_listener(update_listener) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def update_listener(hass, config_entry): + """Update when config_entry options update.""" + _LOGGER.debug("Config entry was updated, rerunning setup") + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + hass.data[DOMAIN][entry.entry_id][CONF_CONFIG_LISTENER]() + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + _LOGGER.debug("Unloaded entry for %s", entry.entry_id) + + return unload_ok + + +async def get_items_of_category(hass: HomeAssistant, entry: ConfigEntry, category: str): + """Return a list of all Control4 items with the specified category.""" + director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] + return_list = [] + for item in director_all_items: + if "categories" in item and category in item["categories"]: + return_list.append(item) + return return_list + + +class Control4Entity(entity.Entity): + """Base entity for Control4.""" + + def __init__( + self, + entry_data: dict, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + name: str, + idx: int, + device_name: str, + device_manufacturer: str, + device_model: str, + device_id: int, + ): + """Initialize a Control4 entity.""" + self.entry = entry + self.entry_data = entry_data + self._name = name + self._idx = idx + self._coordinator = coordinator + self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] + self._device_name = device_name + self._device_manufacturer = device_manufacturer + self._device_model = device_model + self._device_id = device_id + + @property + def name(self): + """Return name of entity.""" + return self._name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._idx + + @property + def device_info(self): + """Return info of parent Control4 device of entity.""" + return { + "config_entry_id": self.entry.entry_id, + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device_name, + "manufacturer": self._device_manufacturer, + "model": self._device_model, + "via_device": (DOMAIN, self._controller_unique_id), + } + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + """Return if entity is available.""" + return self._coordinator.last_update_success + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the state of the device.""" + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py new file mode 100644 index 00000000000..03183edbfda --- /dev/null +++ b/homeassistant/components/control4/config_flow.py @@ -0,0 +1,171 @@ +"""Config flow for Control4 integration.""" +from asyncio import TimeoutError as asyncioTimeoutError +import logging + +from aiohttp.client_exceptions import ClientError +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import NotFound, Unauthorized +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_CONTROLLER_UNIQUE_ID, DEFAULT_SCAN_INTERVAL, MIN_SCAN_INTERVAL +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class Control4Validator: + """Validates that config details can be used to authenticate and communicate with Control4.""" + + def __init__(self, host, username, password, hass): + """Initialize.""" + self.host = host + self.username = username + self.password = password + self.controller_unique_id = None + self.director_bearer_token = None + self.hass = hass + + async def authenticate(self) -> bool: + """Test if we can authenticate with the Control4 account API.""" + try: + account_session = aiohttp_client.async_get_clientsession(self.hass) + account = C4Account(self.username, self.password, account_session) + # Authenticate with Control4 account + await account.getAccountBearerToken() + + # Get controller name + account_controllers = await account.getAccountControllers() + self.controller_unique_id = account_controllers["controllerCommonName"] + + # Get bearer token to communicate with controller locally + self.director_bearer_token = ( + await account.getDirectorBearerToken(self.controller_unique_id) + )["token"] + return True + except (Unauthorized, NotFound): + return False + + async def connect_to_director(self) -> bool: + """Test if we can connect to the local Control4 Director.""" + try: + director_session = aiohttp_client.async_get_clientsession( + self.hass, verify_ssl=False + ) + director = C4Director( + self.host, self.director_bearer_token, director_session + ) + await director.getAllItemInfo() + return True + except (Unauthorized, ClientError, asyncioTimeoutError): + _LOGGER.error("Failed to connect to the Control4 controller") + return False + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Control4.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + + hub = Control4Validator( + user_input["host"], + user_input["username"], + user_input["password"], + self.hass, + ) + try: + if not await hub.authenticate(): + raise InvalidAuth + if not await hub.connect_to_director(): + raise CannotConnect + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + controller_unique_id = hub.controller_unique_id + mac = (controller_unique_id.split("_", 3))[2] + formatted_mac = format_mac(mac) + await self.async_set_unique_id(formatted_mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=controller_unique_id, + data={ + CONF_HOST: user_input["host"], + CONF_USERNAME: user_input["username"], + CONF_PASSWORD: user_input["password"], + CONF_CONTROLLER_UNIQUE_ID: controller_unique_id, + }, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Control4.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)), + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +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/control4/const.py b/homeassistant/components/control4/const.py new file mode 100644 index 00000000000..27590881985 --- /dev/null +++ b/homeassistant/components/control4/const.py @@ -0,0 +1,18 @@ +"""Constants for the Control4 integration.""" + +DOMAIN = "control4" + +DEFAULT_SCAN_INTERVAL = 5 +MIN_SCAN_INTERVAL = 1 + +CONF_ACCOUNT = "account" +CONF_DIRECTOR = "director" +CONF_DIRECTOR_TOKEN_EXPIRATION = "director_token_expiry" +CONF_DIRECTOR_SW_VERSION = "director_sw_version" +CONF_DIRECTOR_MODEL = "director_model" +CONF_DIRECTOR_ALL_ITEMS = "director_all_items" +CONF_CONTROLLER_UNIQUE_ID = "controller_unique_id" + +CONF_CONFIG_LISTENER = "config_listener" + +CONTROL4_ENTITY_TYPE = 7 diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py new file mode 100644 index 00000000000..fc4ca9e358d --- /dev/null +++ b/homeassistant/components/control4/director_utils.py @@ -0,0 +1,62 @@ +"""Provides data updates from the Control4 controller for platforms.""" +import logging + +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import BadToken + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_ACCOUNT, + CONF_CONTROLLER_UNIQUE_ID, + CONF_DIRECTOR, + CONF_DIRECTOR_TOKEN_EXPIRATION, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def director_update_data( + hass: HomeAssistant, entry: ConfigEntry, var: str +) -> dict: + """Retrieve data from the Control4 director for update_coordinator.""" + # possibly implement usage of director_token_expiration to start + # token refresh without waiting for error to occur + try: + director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(var) + except BadToken: + _LOGGER.info("Updating Control4 director token") + await refresh_tokens(hass, entry) + director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(var) + return {key["id"]: key for key in data} + + +async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): + """Store updated authentication and director tokens in hass.data.""" + config = entry.data + account_session = aiohttp_client.async_get_clientsession(hass) + + account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) + await account.getAccountBearerToken() + + controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] + director_token_dict = await account.getDirectorBearerToken(controller_unique_id) + director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + + director = C4Director( + config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session + ) + director_token_expiry = director_token_dict["token_expiration"] + + _LOGGER.debug("Saving new tokens in hass data") + entry_data = hass.data[DOMAIN][entry.entry_id] + entry_data[CONF_ACCOUNT] = account + entry_data[CONF_DIRECTOR] = director + entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_expiry diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py new file mode 100644 index 00000000000..d5a681eac09 --- /dev/null +++ b/homeassistant/components/control4/light.py @@ -0,0 +1,209 @@ +"""Platform for Control4 Lights.""" +import asyncio +from datetime import timedelta +import logging + +from pyControl4.error_handling import C4Exception +from pyControl4.light import C4Light + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import Control4Entity, get_items_of_category +from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN +from .director_utils import director_update_data + +_LOGGER = logging.getLogger(__name__) + +CONTROL4_CATEGORY = "lights" +CONTROL4_NON_DIMMER_VAR = "LIGHT_STATE" +CONTROL4_DIMMER_VAR = "LIGHT_LEVEL" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up Control4 lights from a config entry.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + scan_interval = entry_data[CONF_SCAN_INTERVAL] + _LOGGER.debug( + "Scan interval = %s", scan_interval, + ) + + async def async_update_data_non_dimmer(): + """Fetch data from Control4 director for non-dimmer lights.""" + try: + return await director_update_data(hass, entry, CONTROL4_NON_DIMMER_VAR) + except C4Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") + + async def async_update_data_dimmer(): + """Fetch data from Control4 director for dimmer lights.""" + try: + return await director_update_data(hass, entry, CONTROL4_DIMMER_VAR) + except C4Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") + + non_dimmer_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="light", + update_method=async_update_data_non_dimmer, + update_interval=timedelta(seconds=scan_interval), + ) + dimmer_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="light", + update_method=async_update_data_dimmer, + update_interval=timedelta(seconds=scan_interval), + ) + + # Fetch initial data so we have data when entities subscribe + await non_dimmer_coordinator.async_refresh() + await dimmer_coordinator.async_refresh() + + items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY) + for item in items_of_category: + if item["type"] == CONTROL4_ENTITY_TYPE: + item_name = item["name"] + item_id = item["id"] + item_parent_id = item["parentId"] + item_is_dimmer = item["capabilities"]["dimmer"] + + if item_is_dimmer: + item_coordinator = dimmer_coordinator + else: + item_coordinator = non_dimmer_coordinator + + for parent_item in items_of_category: + if parent_item["id"] == item_parent_id: + item_manufacturer = parent_item["manufacturer"] + item_device_name = parent_item["name"] + item_model = parent_item["model"] + async_add_entities( + [ + Control4Light( + entry_data, + entry, + item_coordinator, + item_name, + item_id, + item_device_name, + item_manufacturer, + item_model, + item_parent_id, + item_is_dimmer, + ) + ], + True, + ) + + +class Control4Light(Control4Entity, LightEntity): + """Control4 light entity.""" + + def __init__( + self, + entry_data: dict, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + name: str, + idx: int, + device_name: str, + device_manufacturer: str, + device_model: str, + device_id: int, + is_dimmer: bool, + ): + """Initialize Control4 light entity.""" + super().__init__( + entry_data, + entry, + coordinator, + name, + idx, + device_name, + device_manufacturer, + device_model, + device_id, + ) + self._is_dimmer = is_dimmer + + def create_api_object(self): + """Create a pyControl4 device object. + + This exists so the director token used is always the latest one, without needing to re-init the entire entity. + """ + return C4Light(self.entry_data[CONF_DIRECTOR], self._idx) + + @property + def is_on(self): + """Return whether this light is on or off.""" + return self._coordinator.data[self._idx]["value"] > 0 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self._is_dimmer: + return round(self._coordinator.data[self._idx]["value"] * 2.55) + return None + + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = 0 + if self._is_dimmer: + flags |= SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION + return flags + + async def async_turn_on(self, **kwargs) -> None: + """Turn the entity on.""" + c4_light = self.create_api_object() + if self._is_dimmer: + if ATTR_TRANSITION in kwargs: + transition_length = kwargs[ATTR_TRANSITION] * 1000 + else: + transition_length = 0 + if ATTR_BRIGHTNESS in kwargs: + brightness = (kwargs[ATTR_BRIGHTNESS] / 255) * 100 + else: + brightness = 100 + await c4_light.rampToLevel(brightness, transition_length) + else: + transition_length = 0 + await c4_light.setLevel(100) + if transition_length == 0: + transition_length = 1000 + delay_time = (transition_length / 1000) + 0.7 + _LOGGER.debug("Delaying light update by %s seconds", delay_time) + await asyncio.sleep(delay_time) + await self._coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + c4_light = self.create_api_object() + if self._is_dimmer: + if ATTR_TRANSITION in kwargs: + transition_length = kwargs[ATTR_TRANSITION] * 1000 + else: + transition_length = 0 + await c4_light.rampToLevel(0, transition_length) + else: + transition_length = 0 + await c4_light.setLevel(0) + if transition_length == 0: + transition_length = 1500 + delay_time = (transition_length / 1000) + 0.7 + _LOGGER.debug("Delaying light update by %s seconds", delay_time) + await asyncio.sleep(delay_time) + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json new file mode 100644 index 00000000000..0d61b080745 --- /dev/null +++ b/homeassistant/components/control4/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "control4", + "name": "Control4", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/control4", + "requirements": ["pyControl4==0.0.6"], + "ssdp": [ + { + "st": "c4:director" + } + ], + "codeowners": ["@lawtancool"] +} diff --git a/homeassistant/components/control4/strings.json b/homeassistant/components/control4/strings.json new file mode 100644 index 00000000000..34331bc18fa --- /dev/null +++ b/homeassistant/components/control4/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "description": "Please enter your Control4 account details and the IP address of your local controller." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between updates" + } + } + } + } +} diff --git a/homeassistant/components/control4/translations/ca.json b/homeassistant/components/control4/translations/ca.json new file mode 100644 index 00000000000..69702272d43 --- /dev/null +++ b/homeassistant/components/control4/translations/ca.json @@ -0,0 +1,31 @@ +{ + "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": { + "host": "Adre\u00e7a IP", + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix els detalls del teu compte Control4 i l'adre\u00e7a IP del teu controlador local." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Segons entre actualitzacions" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/en.json b/homeassistant/components/control4/translations/en.json new file mode 100644 index 00000000000..d8bb94fc0ed --- /dev/null +++ b/homeassistant/components/control4/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "IP Address", + "password": "Password", + "username": "Username" + }, + "description": "Please enter your Control4 account details and the IP address of your local controller." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between updates" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/es.json b/homeassistant/components/control4/translations/es.json new file mode 100644 index 00000000000..c5d49e30680 --- /dev/null +++ b/homeassistant/components/control4/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Fallo al conectar", + "invalid_auth": "Autentificaci\u00f3n invalida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Direcci\u00f3n IP", + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "description": "Por favor, introduzca su cuenta de Control4 y la direcci\u00f3n IP de su controlador local." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Segundos entre actualizaciones" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/it.json b/homeassistant/components/control4/translations/it.json new file mode 100644 index 00000000000..01dfba1bdc5 --- /dev/null +++ b/homeassistant/components/control4/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Indirizzo IP", + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci i dettagli del tuo account Control4 e l'indirizzo IP del controller locale." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Secondi tra gli aggiornamenti" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/ko.json b/homeassistant/components/control4/translations/ko.json new file mode 100644 index 00000000000..ca36da40c18 --- /dev/null +++ b/homeassistant/components/control4/translations/ko.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "IP \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "Control4 \uacc4\uc815 \uc138\ubd80 \uc815\ubcf4\uc640 \ub85c\uceec \ucee8\ud2b8\ub864\ub7ec\uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\uc5c5\ub370\uc774\ud2b8 \uac04\uaca9(\ucd08)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/lb.json b/homeassistant/components/control4/translations/lb.json new file mode 100644 index 00000000000..782a5ebe705 --- /dev/null +++ b/homeassistant/components/control4/translations/lb.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "host": "IP Adress", + "password": "Passwuert", + "username": "Benotzernumm" + }, + "description": "G\u00ebff deng Control4 Kont Informatiounen an d'IP Adress vun dengem lokale Kontroller an." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Sekonnen t\u00ebscht Atkualis\u00e9ierungen" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/no.json b/homeassistant/components/control4/translations/no.json new file mode 100644 index 00000000000..bc6bcc64462 --- /dev/null +++ b/homeassistant/components/control4/translations/no.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "IP adresse", + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Vennligst skriv inn Control4-kontodetaljene og IP-adressen til din lokale kontroller." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Sekunder mellom oppdateringer" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/pl.json b/homeassistant/components/control4/translations/pl.json new file mode 100644 index 00000000000..3064a0044b1 --- /dev/null +++ b/homeassistant/components/control4/translations/pl.json @@ -0,0 +1,31 @@ +{ + "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": { + "host": "Adres IP", + "password": "[%key_id:common::config_flow::data::password%]", + "username": "[%key_id:common::config_flow::data::username%]" + }, + "description": "Prosz\u0119 wprowadzi\u0107 dane swojego konta Control4 oraz adres IP lokalnego kontrolera." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji [sekundy]" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/ru.json b/homeassistant/components/control4/translations/ru.json new file mode 100644 index 00000000000..315fbb7b3f3 --- /dev/null +++ b/homeassistant/components/control4/translations/ru.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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": "IP-\u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Control4 \u0438 IP-\u0430\u0434\u0440\u0435\u0441 \u0412\u0430\u0448\u0435\u0433\u043e \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043c\u0435\u0436\u0434\u0443 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043c\u0438 (\u0441\u0435\u043a.)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/sl.json b/homeassistant/components/control4/translations/sl.json new file mode 100644 index 00000000000..f259716cce2 --- /dev/null +++ b/homeassistant/components/control4/translations/sl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "IP naslov", + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "Vnesite podatke o ra\u010dunu Control4 in IP naslov lokalnega regulatorja." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Sekunde med posodobitvami" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/uk.json b/homeassistant/components/control4/translations/uk.json new file mode 100644 index 00000000000..6c0426eba8f --- /dev/null +++ b/homeassistant/components/control4/translations/uk.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0421\u0435\u043a\u0443\u043d\u0434 \u043c\u0456\u0436 \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044f\u043c\u0438" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/zh-Hant.json b/homeassistant/components/control4/translations/zh-Hant.json new file mode 100644 index 00000000000..f52e877a9d4 --- /dev/null +++ b/homeassistant/components/control4/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\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": { + "host": "IP \u4f4d\u5740", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165 Control4 \u5e33\u865f\u8cc7\u8a0a\u8207\u672c\u5730\u7aef\u63a7\u5236\u5668 IP \u4f4d\u5740\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9593\u9694\u79d2\u6578" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index dba4ff8be89..29dd97909e3 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -16,6 +16,7 @@ from homeassistant.const import ( SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, ) from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry @@ -31,9 +32,10 @@ from . import ( SUPPORT_OPEN_TILT, SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, ) -CMD_ACTION_TYPES = {"open", "close", "open_tilt", "close_tilt"} +CMD_ACTION_TYPES = {"open", "close", "stop", "open_tilt", "close_tilt"} POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} CMD_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( @@ -99,6 +101,15 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: CONF_TYPE: "close", } ) + if supported_features & SUPPORT_STOP: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "stop", + } + ) if supported_features & SUPPORT_SET_TILT_POSITION: actions.append( @@ -160,6 +171,8 @@ async def async_call_action_from_config( service = SERVICE_OPEN_COVER elif config[CONF_TYPE] == "close": service = SERVICE_CLOSE_COVER + elif config[CONF_TYPE] == "stop": + service = SERVICE_STOP_COVER elif config[CONF_TYPE] == "open_tilt": service = SERVICE_OPEN_COVER_TILT elif config[CONF_TYPE] == "close_tilt": diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index de52614891f..cb98c542d43 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -4,6 +4,7 @@ "action_type": { "open": "Open {entity_name}", "close": "Close {entity_name}", + "stop": "Stop {entity_name}", "open_tilt": "Open {entity_name} tilt", "close_tilt": "Close {entity_name} tilt", "set_position": "Set {entity_name} position", diff --git a/homeassistant/components/cover/translations/ca.json b/homeassistant/components/cover/translations/ca.json index 970661be215..d033606abdd 100644 --- a/homeassistant/components/cover/translations/ca.json +++ b/homeassistant/components/cover/translations/ca.json @@ -6,7 +6,8 @@ "open": "Obre {entity_name}", "open_tilt": "Inclinaci\u00f3 {entity_name} obert/a", "set_position": "Estableix la posici\u00f3 de {entity_name}", - "set_tilt_position": "Estableix la inclinaci\u00f3 de {entity_name}" + "set_tilt_position": "Estableix la inclinaci\u00f3 de {entity_name}", + "stop": "Atura {entity_name}" }, "condition_type": { "is_closed": "{entity_name} est\u00e0 tancat/da", diff --git a/homeassistant/components/cover/translations/cs.json b/homeassistant/components/cover/translations/cs.json index c32db1e8b97..b093fb9f214 100644 --- a/homeassistant/components/cover/translations/cs.json +++ b/homeassistant/components/cover/translations/cs.json @@ -1,5 +1,8 @@ { "device_automation": { + "action_type": { + "stop": "Zastavit {entity_name}" + }, "condition_type": { "is_closed": "{entity_name} je zav\u0159eno", "is_closing": "{entity_name} se zav\u00edr\u00e1", diff --git a/homeassistant/components/cover/translations/en.json b/homeassistant/components/cover/translations/en.json index de2ad4e0b15..c78898872c9 100644 --- a/homeassistant/components/cover/translations/en.json +++ b/homeassistant/components/cover/translations/en.json @@ -6,7 +6,8 @@ "open": "Open {entity_name}", "open_tilt": "Open {entity_name} tilt", "set_position": "Set {entity_name} position", - "set_tilt_position": "Set {entity_name} tilt position" + "set_tilt_position": "Set {entity_name} tilt position", + "stop": "Stop {entity_name}" }, "condition_type": { "is_closed": "{entity_name} is closed", diff --git a/homeassistant/components/cover/translations/es.json b/homeassistant/components/cover/translations/es.json index 857813eefb5..181cda45fb5 100644 --- a/homeassistant/components/cover/translations/es.json +++ b/homeassistant/components/cover/translations/es.json @@ -6,7 +6,8 @@ "open": "Abrir {entity_name}", "open_tilt": "Abrir inclinaci\u00f3n de {entity_name}", "set_position": "Ajustar la posici\u00f3n de {entity_name}", - "set_tilt_position": "Ajustar la posici\u00f3n de inclinaci\u00f3n de {entity_name}" + "set_tilt_position": "Ajustar la posici\u00f3n de inclinaci\u00f3n de {entity_name}", + "stop": "Detener {entity_name}" }, "condition_type": { "is_closed": "{entity_name} est\u00e1 cerrado", diff --git a/homeassistant/components/cover/translations/it.json b/homeassistant/components/cover/translations/it.json index 95f2e34d8eb..90322b9f122 100644 --- a/homeassistant/components/cover/translations/it.json +++ b/homeassistant/components/cover/translations/it.json @@ -6,7 +6,8 @@ "open": "Apri {entity_name}", "open_tilt": "Apri l'inclinazione di {entity_name}", "set_position": "Imposta la posizione di {entity_name}", - "set_tilt_position": "Imposta la posizione di inclinazione di {entity_name}" + "set_tilt_position": "Imposta la posizione di inclinazione di {entity_name}", + "stop": "Ferma {entity_name}" }, "condition_type": { "is_closed": "{entity_name} \u00e8 chiuso", diff --git a/homeassistant/components/cover/translations/no.json b/homeassistant/components/cover/translations/no.json index eaa0f2d1678..a9f1cbafb18 100644 --- a/homeassistant/components/cover/translations/no.json +++ b/homeassistant/components/cover/translations/no.json @@ -6,7 +6,8 @@ "open": "\u00c5pne {entity_name}", "open_tilt": "\u00c5pne {entity_name} tilt", "set_position": "Angi {entity_name} posisjon", - "set_tilt_position": "Angi {entity_name} tilt posisjon" + "set_tilt_position": "Angi {entity_name} tilt posisjon", + "stop": "Stopp {entity_name}" }, "condition_type": { "is_closed": "{entity_name} er lukket", diff --git a/homeassistant/components/cover/translations/ru.json b/homeassistant/components/cover/translations/ru.json index 53d646fc09f..9b302e0e52a 100644 --- a/homeassistant/components/cover/translations/ru.json +++ b/homeassistant/components/cover/translations/ru.json @@ -6,7 +6,8 @@ "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c {entity_name}", "open_tilt": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c {entity_name} \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043d\u0430\u043a\u043b\u043e\u043d\u0430", "set_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 {entity_name}", - "set_tilt_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 {entity_name}" + "set_tilt_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 {entity_name}", + "stop": "\u041e\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c {entity_name}" }, "condition_type": { "is_closed": "{entity_name} \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", diff --git a/homeassistant/components/cover/translations/uk.json b/homeassistant/components/cover/translations/uk.json index 0e0917177e6..0485a9bb371 100644 --- a/homeassistant/components/cover/translations/uk.json +++ b/homeassistant/components/cover/translations/uk.json @@ -1,4 +1,9 @@ { + "device_automation": { + "action_type": { + "stop": "\u0417\u0443\u043f\u0438\u043d\u0438\u0442\u0438 {entity_name}" + } + }, "state": { "_": { "closed": "\u0417\u0430\u0447\u0438\u043d\u0435\u043d\u043e", diff --git a/homeassistant/components/cover/translations/zh-Hant.json b/homeassistant/components/cover/translations/zh-Hant.json index 31c0900af9a..a8752b13f00 100644 --- a/homeassistant/components/cover/translations/zh-Hant.json +++ b/homeassistant/components/cover/translations/zh-Hant.json @@ -6,7 +6,8 @@ "open": "\u958b\u555f{entity_name}", "open_tilt": "\u958b\u555f{entity_name}\u7a97\u7c3e", "set_position": "\u8a2d\u5b9a{entity_name}\u4f4d\u7f6e", - "set_tilt_position": "\u8a2d\u5b9a{entity_name}\u5e8a\u7c3e\u4f4d\u7f6e" + "set_tilt_position": "\u8a2d\u5b9a{entity_name}\u5e8a\u7c3e\u4f4d\u7f6e", + "stop": "\u505c\u6b62 {entity_name}" }, "condition_type": { "is_closed": "{entity_name}\u5df2\u95dc\u9589", diff --git a/homeassistant/components/daikin/translations/nl.json b/homeassistant/components/daikin/translations/nl.json index 6fa2362ee59..775e358d205 100644 --- a/homeassistant/components/daikin/translations/nl.json +++ b/homeassistant/components/daikin/translations/nl.json @@ -6,7 +6,8 @@ "step": { "user": { "data": { - "host": "Host" + "host": "Host", + "password": "Wachtwoord" }, "description": "Voer het IP-adres van uw Daikin AC in.", "title": "Daikin AC instellen" diff --git a/homeassistant/components/daikin/translations/no.json b/homeassistant/components/daikin/translations/no.json index 98d93a29952..cb2f8cde40b 100644 --- a/homeassistant/components/daikin/translations/no.json +++ b/homeassistant/components/daikin/translations/no.json @@ -3,12 +3,19 @@ "abort": { "already_configured": "Enheten er allerede konfigurert" }, + "error": { + "device_fail": "Uventet feil", + "device_timeout": "Tilkobling mislyktes.", + "forbidden": "Ugyldig godkjenning" + }, "step": { "user": { "data": { - "host": "Vert" + "host": "Vert", + "key": "API-n\u00f8kkel", + "password": "Passord" }, - "description": "Fyll inn IP-adressen til din Daikin AC.", + "description": "Angi IP-adressen til Daikin AC. \n\n Merk at API-n\u00f8kkel og Passord brukes av henholdsvis BRP072Cxx og SKYFi-enheter.", "title": "Konfigurer Daikin AC" } } diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index b0486a99dc8..6e5f4a11ca4 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -237,6 +237,7 @@ AQARA_CUBE = { } AQARA_DOUBLE_WALL_SWITCH_MODEL = "lumi.remote.b286acn01" +AQARA_DOUBLE_WALL_SWITCH_MODEL_2020 = "lumi.remote.b286acn02" AQARA_DOUBLE_WALL_SWITCH = { (CONF_SHORT_PRESS, CONF_LEFT): {CONF_EVENT: 1002}, (CONF_LONG_PRESS, CONF_LEFT): {CONF_EVENT: 1001}, @@ -357,6 +358,7 @@ REMOTES = { AQARA_CUBE_MODEL: AQARA_CUBE, AQARA_CUBE_MODEL_ALT1: AQARA_CUBE, AQARA_DOUBLE_WALL_SWITCH_MODEL: AQARA_DOUBLE_WALL_SWITCH, + AQARA_DOUBLE_WALL_SWITCH_MODEL_2020: AQARA_DOUBLE_WALL_SWITCH, AQARA_DOUBLE_WALL_SWITCH_WXKG02LM_MODEL: AQARA_DOUBLE_WALL_SWITCH_WXKG02LM, AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH_WXKG03LM, AQARA_MINI_SWITCH_MODEL: AQARA_MINI_SWITCH, diff --git a/homeassistant/components/deconz/translations/no.json b/homeassistant/components/deconz/translations/no.json index f25ad1d5886..231901c4cd4 100644 --- a/homeassistant/components/deconz/translations/no.json +++ b/homeassistant/components/deconz/translations/no.json @@ -24,7 +24,7 @@ "manual_input": { "data": { "host": "Vert", - "port": "Port" + "port": "" } }, "user": { diff --git a/homeassistant/components/deconz/translations/tr.json b/homeassistant/components/deconz/translations/tr.json new file mode 100644 index 00000000000..e73703043f3 --- /dev/null +++ b/homeassistant/components/deconz/translations/tr.json @@ -0,0 +1,9 @@ +{ + "options": { + "step": { + "deconz_devices": { + "title": "deCONZ se\u00e7enekleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 9cfb5582acc..f00f1fb781e 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -61,7 +61,6 @@ YOUTUBE_PLAYER_SUPPORT = ( | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE - | SUPPORT_SELECT_SOURCE | SUPPORT_SEEK ) @@ -397,6 +396,7 @@ class DemoTVShowPlayer(AbstractDemoPlayer): self._cur_episode = 1 self._episode_count = 13 self._source = "dvd" + self._source_list = ["dvd", "youtube"] @property def media_content_id(self): @@ -448,6 +448,11 @@ class DemoTVShowPlayer(AbstractDemoPlayer): """Return the current input source.""" return self._source + @property + def source_list(self): + """List of available sources.""" + return self._source_list + @property def supported_features(self): """Flag media player features that are supported.""" diff --git a/homeassistant/components/demo/translations/es.json b/homeassistant/components/demo/translations/es.json index bcf9dbbcbcf..19ebc05d089 100644 --- a/homeassistant/components/demo/translations/es.json +++ b/homeassistant/components/demo/translations/es.json @@ -1,12 +1,6 @@ { "options": { "step": { - "init": { - "data": { - "one": "Vacio", - "other": "Vacio" - } - }, "options_1": { "data": { "bool": "Booleano opcional", diff --git a/homeassistant/components/demo/translations/it.json b/homeassistant/components/demo/translations/it.json index 16477633de2..50939df5631 100644 --- a/homeassistant/components/demo/translations/it.json +++ b/homeassistant/components/demo/translations/it.json @@ -10,6 +10,7 @@ "options_1": { "data": { "bool": "Valore booleano facoltativo", + "constant": "Costante", "int": "Input numerico" } }, diff --git a/homeassistant/components/demo/translations/lb.json b/homeassistant/components/demo/translations/lb.json index e138b7d7fa4..bfb094c00f9 100644 --- a/homeassistant/components/demo/translations/lb.json +++ b/homeassistant/components/demo/translations/lb.json @@ -4,6 +4,7 @@ "options_1": { "data": { "bool": "Optionelle Boolean", + "constant": "Konstant", "int": "Numeresch Agab" } }, diff --git a/homeassistant/components/demo/translations/no.json b/homeassistant/components/demo/translations/no.json index e85f5b067a0..5003b9da568 100644 --- a/homeassistant/components/demo/translations/no.json +++ b/homeassistant/components/demo/translations/no.json @@ -4,6 +4,7 @@ "options_1": { "data": { "bool": "Valgfri boolean", + "constant": "Konstant", "int": "Numerisk inndata" } }, diff --git a/homeassistant/components/demo/translations/sl.json b/homeassistant/components/demo/translations/sl.json index 33e4ece832c..22cca21db4c 100644 --- a/homeassistant/components/demo/translations/sl.json +++ b/homeassistant/components/demo/translations/sl.json @@ -12,6 +12,7 @@ "options_1": { "data": { "bool": "Izbirna logi\u010dna vrednost", + "constant": "Constant", "int": "\u0160tevil\u010dni vnos" } }, diff --git a/homeassistant/components/denonavr/translations/es.json b/homeassistant/components/denonavr/translations/es.json index 69568002c35..2332d61c967 100644 --- a/homeassistant/components/denonavr/translations/es.json +++ b/homeassistant/components/denonavr/translations/es.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n para este AVR Denon ya est\u00e1 en marcha.", - "connection_error": "No se ha podido conectar, por favor, int\u00e9ntelo de nuevo.", + "connection_error": "Fallo en la conexi\u00f3n, por favor int\u00e9ntalo de nuevo, desconectar la alimentaci\u00f3n y los cables de ethernet y reconectarlos puede ayudar.", "not_denonavr_manufacturer": "No es un Receptor AVR Denon AVR en Red, el fabricante detectado no concuerda", "not_denonavr_missing": "No es un Receptor AVR Denon AVR en Red, la informaci\u00f3n detectada no est\u00e1 completa" }, diff --git a/homeassistant/components/denonavr/translations/nl.json b/homeassistant/components/denonavr/translations/nl.json new file mode 100644 index 00000000000..3444568d459 --- /dev/null +++ b/homeassistant/components/denonavr/translations/nl.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "connection_error": "Kan geen verbinding maken, probeer het opnieuw, het kan helpen om de netvoeding en ethernetkabels los te koppelen en opnieuw aan te sluiten" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/no.json b/homeassistant/components/denonavr/translations/no.json index 93f58eedf6a..e156101c378 100644 --- a/homeassistant/components/denonavr/translations/no.json +++ b/homeassistant/components/denonavr/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyt for denne Denon AVR p\u00e5g\u00e5r allerede", "connection_error": "Kunne ikke koble til, vennligst pr\u00f8v igjen. Koble fra str\u00f8m- og nettverkskablene og koble dem til igjen kan hjelpe", "not_denonavr_manufacturer": "Ikke en Denon AVR Network Receiver, oppdaget manafucturer stemte ikke overens", diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index 3f88212646d..26c27f59ae8 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -50,10 +50,21 @@ class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): def __init__(self, homecontrol, device_instance, element_uid): """Initialize a devolo binary sensor.""" - if device_instance.binary_sensor_property.get(element_uid).sub_type != "": - name = f"{device_instance.itemName} {device_instance.binary_sensor_property.get(element_uid).sub_type}" - else: - name = f"{device_instance.itemName} {device_instance.binary_sensor_property.get(element_uid).sensor_type}" + self._binary_sensor_property = device_instance.binary_sensor_property.get( + element_uid + ) + + self._device_class = DEVICE_CLASS_MAPPING.get( + self._binary_sensor_property.sub_type + or self._binary_sensor_property.sensor_type + ) + name = device_instance.itemName + + if self._device_class is None: + if device_instance.binary_sensor_property.get(element_uid).sub_type != "": + name += f" {device_instance.binary_sensor_property.get(element_uid).sub_type}" + else: + name += f" {device_instance.binary_sensor_property.get(element_uid).sensor_type}" super().__init__( homecontrol=homecontrol, @@ -63,15 +74,6 @@ class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): sync=self._sync, ) - self._binary_sensor_property = self._device_instance.binary_sensor_property.get( - self._unique_id - ) - - self._device_class = DEVICE_CLASS_MAPPING.get( - self._binary_sensor_property.sub_type - or self._binary_sensor_property.sensor_type - ) - self._state = self._binary_sensor_property.state self._subscriber = None diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index 599e44fe8f0..60923235916 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -3,6 +3,6 @@ DOMAIN = "devolo_home_control" DEFAULT_MYDEVOLO = "https://www.mydevolo.com" DEFAULT_MPRM = "https://homecontrol.mydevolo.com" -PLATFORMS = ["binary_sensor", "sensor", "switch"] +PLATFORMS = ["binary_sensor", "light", "sensor", "switch"] CONF_MYDEVOLO = "mydevolo_url" CONF_HOMECONTROL = "home_control_url" diff --git a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py new file mode 100644 index 00000000000..897899e725c --- /dev/null +++ b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py @@ -0,0 +1,35 @@ +"""Base class for multi level switches in devolo Home Control.""" +import logging + +from .devolo_device import DevoloDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): + """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" + + def __init__(self, homecontrol, device_instance, element_uid): + """Initialize a multi level switch within devolo Home Control.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + name=f"{device_instance.itemName}", + sync=self._sync, + ) + self._multi_level_switch_property = device_instance.multi_level_switch_property[ + element_uid + ] + + self._value = self._multi_level_switch_property.value + + def _sync(self, message): + """Update the multi level switch state.""" + if message[0] == self._multi_level_switch_property.element_uid: + self._value = message[1] + elif message[0].startswith("hdm"): + self._available = self._device_instance.is_online() + else: + _LOGGER.debug("No valid message received: %s", message) + self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py new file mode 100644 index 00000000000..c2f678425be --- /dev/null +++ b/homeassistant/components/devolo_home_control/light.py @@ -0,0 +1,80 @@ +"""Platform for light integration.""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN +from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Get all light devices and setup them via config entry.""" + entities = [] + + for device in hass.data[DOMAIN]["homecontrol"].multi_level_switch_devices: + for multi_level_switch in device.multi_level_switch_property.values(): + if multi_level_switch.switch_type == "dimmer": + entities.append( + DevoloLightDeviceEntity( + homecontrol=hass.data[DOMAIN]["homecontrol"], + device_instance=device, + element_uid=multi_level_switch.element_uid, + ) + ) + + async_add_entities(entities, False) + + +class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): + """Representation of a light within devolo Home Control.""" + + def __init__(self, homecontrol, device_instance, element_uid): + """Initialize a devolo multi level switch.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + + self._binary_switch_property = device_instance.binary_switch_property.get( + element_uid.replace("Dimmer", "BinarySwitch") + ) + + @property + def brightness(self): + """Return the brightness value of the light.""" + return round(self._value / 100 * 255) + + @property + def is_on(self): + """Return the state of the light.""" + return bool(self._value) + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS + + def turn_on(self, **kwargs) -> None: + """Turn device on.""" + if kwargs.get(ATTR_BRIGHTNESS) is not None: + self._multi_level_switch_property.set( + round(kwargs[ATTR_BRIGHTNESS] / 255 * 100) + ) + else: + if self._binary_switch_property is not None: + # Turn on the light device to the latest known value. The value is known by the device itself. + self._binary_switch_property.set(True) + else: + # If there is no binary switch attached to the device, turn it on to 100 %. + self._multi_level_switch_property.set(100) + + def turn_off(self, **kwargs) -> None: + """Turn device off.""" + self._multi_level_switch_property.set(0) diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 0f02f6d0dd0..31bee42e4a2 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -53,13 +53,19 @@ class DevoloMultiLevelDeviceEntity(DevoloDeviceEntity): self._device_class = DEVICE_CLASS_MAPPING.get( self._multi_level_sensor_property.sensor_type ) + + name = device_instance.itemName + + if self._device_class is None: + name += f" {self._multi_level_sensor_property.sensor_type}" + self._unit = self._multi_level_sensor_property.unit super().__init__( homecontrol=homecontrol, device_instance=device_instance, element_uid=element_uid, - name=f"{device_instance.itemName} {self._multi_level_sensor_property.sensor_type}", + name=name, sync=self._sync, ) diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index e70474a8d5d..d14b6c059de 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -19,13 +19,15 @@ async def async_setup_entry( entities = [] for device in devices: for binary_switch in device.binary_switch_property: - entities.append( - DevoloSwitch( - homecontrol=hass.data[DOMAIN]["homecontrol"], - device_instance=device, - element_uid=binary_switch, + # Exclude the binary switch which have also a multi_level_switches here, because they are implemented as light devices now. + if not hasattr(device, "multi_level_switch_property"): + entities.append( + DevoloSwitch( + homecontrol=hass.data[DOMAIN]["homecontrol"], + device_instance=device, + element_uid=binary_switch, + ) ) - ) async_add_entities(entities) diff --git a/homeassistant/components/dexcom/translations/no.json b/homeassistant/components/dexcom/translations/no.json index bd32458f907..61ad015b5a4 100644 --- a/homeassistant/components/dexcom/translations/no.json +++ b/homeassistant/components/dexcom/translations/no.json @@ -1,9 +1,30 @@ { "config": { + "abort": { + "already_configured_account": "Kontoen er allerede konfigurert" + }, + "error": { + "account_error": "Ugyldig godkjenning", + "session_error": "Tilkobling mislyktes.", + "unknown": "Uventet feil" + }, "step": { "user": { "data": { - "server": "" + "password": "Passord", + "server": "", + "username": "Brukernavn" + }, + "description": "Angi Dexcom Share-legitimasjon", + "title": "Setup Dexcom integrasjon" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "unit_of_measurement": "M\u00e5leenhet" } } } diff --git a/homeassistant/components/dexcom/translations/pl.json b/homeassistant/components/dexcom/translations/pl.json index c3e4e95f47b..24ae7a17370 100644 --- a/homeassistant/components/dexcom/translations/pl.json +++ b/homeassistant/components/dexcom/translations/pl.json @@ -1,4 +1,25 @@ { + "config": { + "abort": { + "already_configured_account": "Konto jest ju\u017c skonfigurowane." + }, + "error": { + "account_error": "Niepoprawne uwierzytelnienie.", + "session_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "password": "[%key_id:common::config_flow::data::password%]", + "server": "Serwer", + "username": "[%key_id:common::config_flow::data::username%]" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce Dexcom", + "title": "Skonfiguruj integracj\u0119 Dexcom" + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/directv/translations/es.json b/homeassistant/components/directv/translations/es.json index e6a0d6d07ea..f1d896e698b 100644 --- a/homeassistant/components/directv/translations/es.json +++ b/homeassistant/components/directv/translations/es.json @@ -14,7 +14,7 @@ }, "user": { "data": { - "host": "Host o direcci\u00f3n IP" + "host": "Host" } } } diff --git a/homeassistant/components/directv/translations/no.json b/homeassistant/components/directv/translations/no.json index d4f9e1f72de..c6db33d32d0 100644 --- a/homeassistant/components/directv/translations/no.json +++ b/homeassistant/components/directv/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "DirecTV-mottaker er allerede konfigurert", + "already_configured": "Enheten er allerede konfigurert", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 37320c80008..921b76168ca 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_discover, async_load_platform @@ -71,7 +71,6 @@ SERVICE_HANDLERS = { "bose_soundtouch": ("media_player", "soundtouch"), "bluesound": ("media_player", "bluesound"), "kodi": ("media_player", "kodi"), - "volumio": ("media_player", "volumio"), "lg_smart_device": ("media_player", "lg_soundbar"), "nanoleaf_aurora": ("light", "nanoleaf"), } @@ -93,6 +92,7 @@ MIGRATED_SERVICE_HANDLERS = [ "songpal", SERVICE_WEMO, SERVICE_XIAOMI_GW, + "volumio", ] DEFAULT_ENABLED = ( @@ -209,7 +209,7 @@ async def async_setup(hass, config): """Schedule the first discovery when Home Assistant starts up.""" async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow()) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, schedule_first) return True diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 58311fa65e4..23495a22bf8 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -2,7 +2,7 @@ "domain": "doorbird", "name": "DoorBird", "documentation": "https://www.home-assistant.io/integrations/doorbird", - "requirements": ["doorbirdpy==2.0.8"], + "requirements": ["doorbirdpy==2.1.0"], "dependencies": ["http"], "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@oblogic7", "@bdraco"], diff --git a/homeassistant/components/doorbird/translations/es.json b/homeassistant/components/doorbird/translations/es.json index b9c77b9ae91..2bf3ff7fc25 100644 --- a/homeassistant/components/doorbird/translations/es.json +++ b/homeassistant/components/doorbird/translations/es.json @@ -14,7 +14,7 @@ "step": { "user": { "data": { - "host": "Host (Direcci\u00f3n IP)", + "host": "Host", "name": "Nombre del dispositivo", "password": "Contrase\u00f1a", "username": "Usuario" diff --git a/homeassistant/components/doorbird/translations/no.json b/homeassistant/components/doorbird/translations/no.json index 8f3a580e43e..4929e58c61f 100644 --- a/homeassistant/components/doorbird/translations/no.json +++ b/homeassistant/components/doorbird/translations/no.json @@ -6,6 +6,7 @@ "not_doorbird_device": "Denne enheten er ikke en DoorBird" }, "error": { + "cannot_connect": "Tilkobling mislyktes.", "invalid_auth": "Ugyldig godkjenning", "unknown": "Uventet feil" }, diff --git a/homeassistant/components/dunehd/translations/no.json b/homeassistant/components/dunehd/translations/no.json index 061809a1c30..e395c28b7a9 100644 --- a/homeassistant/components/dunehd/translations/no.json +++ b/homeassistant/components/dunehd/translations/no.json @@ -1,10 +1,18 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, "error": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes.", "invalid_host": "Ugyldig vertsnavn eller IP-adresse." }, "step": { "user": { + "data": { + "host": "Vert" + }, "description": "Konfigurer Dune HD-integrering. Hvis du har problemer med konfigurasjonen, kan du g\u00e5 til: https://www.home-assistant.io/integrations/dunehd \n\nKontroller at spilleren er sl\u00e5tt p\u00e5.", "title": "" } diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json index 35a76180e2e..94a29d1615d 100644 --- a/homeassistant/components/dyson/manifest.json +++ b/homeassistant/components/dyson/manifest.json @@ -2,7 +2,7 @@ "domain": "dyson", "name": "Dyson", "documentation": "https://www.home-assistant.io/integrations/dyson", - "requirements": ["libpurecool==0.6.1"], + "requirements": ["libpurecool==0.6.3"], "after_dependencies": ["zeroconf"], "codeowners": ["@etheralm"] } diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index 2f3e05fd720..8411980ee44 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -55,21 +55,21 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_abort(reason="connection_error") - # Hostname is format: my-ke.local. - host = user_input["hostname"].rstrip(".") try: - info = await self._get_elgato_info(host, user_input[CONF_PORT]) + info = await self._get_elgato_info( + user_input[CONF_HOST], user_input[CONF_PORT] + ) except ElgatoError: return self.async_abort(reason="connection_error") # Check if already configured await self.async_set_unique_id(info.serial_number) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update( { - CONF_HOST: host, + CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], CONF_SERIAL_NUMBER: info.serial_number, "title_placeholders": {"serial_number": info.serial_number}, diff --git a/homeassistant/components/elgato/translations/no.json b/homeassistant/components/elgato/translations/no.json index 54b84966cdc..bb7e56211de 100644 --- a/homeassistant/components/elgato/translations/no.json +++ b/homeassistant/components/elgato/translations/no.json @@ -12,7 +12,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "description": "Sett opp Elgato Key Light for \u00e5 integrere med Home Assistant." }, diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 20b8195d5b8..ca694157ba7 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -2,7 +2,7 @@ "domain": "elkm1", "name": "Elk-M1 Control", "documentation": "https://www.home-assistant.io/integrations/elkm1", - "requirements": ["elkm1-lib==0.7.18"], - "codeowners": ["@bdraco"], + "requirements": ["elkm1-lib==0.7.19"], + "codeowners": ["@gwww", "@bdraco"], "config_flow": true } diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 5eb32939d51..b84e64e6cc6 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -2,6 +2,7 @@ import asyncio import hashlib import logging +import time from homeassistant import core from homeassistant.components import ( @@ -66,10 +67,16 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.network import is_local _LOGGER = logging.getLogger(__name__) +# How long to wait for a state change to happen +STATE_CHANGE_WAIT_TIMEOUT = 5.0 +# How long an entry state's cache will be valid for in seconds. +STATE_CACHED_TIMEOUT = 2.0 + STATE_BRIGHTNESS = "bri" STATE_COLORMODE = "colormode" STATE_HUE = "hue" @@ -515,13 +522,6 @@ class HueOneLightChangeView(HomeAssistantView): if entity.domain in config.off_maps_to_on_domains: service = SERVICE_TURN_ON - # Caching is required because things like scripts and scenes won't - # report as "off" to Alexa if an "off" command is received, because - # they'll map to "on". Thus, instead of reporting its actual - # status, we report what Alexa will want to see, which is the same - # as the actual requested command. - config.cached_states[entity_id] = parsed - # Separate call to turn on needed if turn_on_needed: hass.async_create_task( @@ -534,10 +534,18 @@ class HueOneLightChangeView(HomeAssistantView): ) if service is not None: + state_will_change = parsed[STATE_ON] != (entity.state != STATE_OFF) + hass.async_create_task( hass.services.async_call(domain, service, data, blocking=True) ) + if state_will_change: + # Wait for the state to change. + await wait_for_state_change_or_timeout( + hass, entity_id, STATE_CACHED_TIMEOUT + ) + # Create success responses for all received keys json_response = [ create_hue_success_response( @@ -556,16 +564,40 @@ class HueOneLightChangeView(HomeAssistantView): create_hue_success_response(entity_number, val, parsed[key]) ) - # Echo fetches the state immediately after the PUT method returns. - # Waiting for a short time allows the changes to propagate. - await asyncio.sleep(0.25) + if entity.domain in config.off_maps_to_on_domains: + # Caching is required because things like scripts and scenes won't + # report as "off" to Alexa if an "off" command is received, because + # they'll map to "on". Thus, instead of reporting its actual + # status, we report what Alexa will want to see, which is the same + # as the actual requested command. + config.cached_states[entity_id] = [parsed, None] + else: + config.cached_states[entity_id] = [parsed, time.time()] return self.json(json_response) def get_entity_state(config, entity): """Retrieve and convert state and brightness values for an entity.""" - cached_state = config.cached_states.get(entity.entity_id, None) + cached_state_entry = config.cached_states.get(entity.entity_id, None) + cached_state = None + + # Check if we have a cached entry, and if so if it hasn't expired. + if cached_state_entry is not None: + entry_state, entry_time = cached_state_entry + if entry_time is None: + # Handle the case where the entity is listed in config.off_maps_to_on_domains. + cached_state = entry_state + elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[ + STATE_ON + ] == (entity.state != STATE_OFF): + # We only want to use the cache if the actual state of the entity + # is in sync so that it can be detected as an error by Alexa. + cached_state = entry_state + else: + # Remove the now stale cached entry. + config.cached_states.pop(entity.entity_id) + data = { STATE_ON: False, STATE_BRIGHTNESS: None, @@ -744,12 +776,11 @@ def entity_to_json(config, entity): retval["modelid"] = "HASS123" retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) else: - # Dimmable light (Zigbee Device ID: 0x0100) - # Supports groups, scenes, on/off and dimming - # Reports fixed brightness for compatibility with Alexa. - retval["type"] = "Dimmable light" - retval["modelid"] = "HASS123" - retval["state"].update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) + # On/Off light (ZigBee Device ID: 0x0000) + # Supports groups, scenes and on/off control + retval["type"] = "On/Off light" + retval["productname"] = "On/Off light" + retval["modelid"] = "HASS321" return retval @@ -792,3 +823,21 @@ def hue_brightness_to_hass(value): def hass_to_hue_brightness(value): """Convert hass brightness 0..255 to hue 1..254 scale.""" return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) + + +async def wait_for_state_change_or_timeout(hass, entity_id, timeout): + """Wait for an entity to change state.""" + ev = asyncio.Event() + + @core.callback + def _async_event_changed(_): + ev.set() + + unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed) + + try: + await asyncio.wait_for(ev.wait(), timeout=STATE_CHANGE_WAIT_TIMEOUT) + except asyncio.TimeoutError: + pass + finally: + unsub() diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index ecb78241771..8adcac1a52f 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -74,11 +74,14 @@ class UPNPResponderThread(threading.Thread): self.upnp_bind_multicast = upnp_bind_multicast self.advertise_ip = advertise_ip self.advertise_port = advertise_port + self._ssdp_socket = None def run(self): """Run the server.""" # Listen for UDP port 1900 packets sent to SSDP multicast address - ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._ssdp_socket = ssdp_socket = socket.socket( + socket.AF_INET, socket.SOCK_DGRAM + ) ssdp_socket.setblocking(False) # Required for receiving multicast @@ -101,7 +104,6 @@ class UPNPResponderThread(threading.Thread): while True: if self._interrupted: - clean_socket_close(ssdp_socket) return try: @@ -114,7 +116,6 @@ class UPNPResponderThread(threading.Thread): continue except OSError as ex: if self._interrupted: - clean_socket_close(ssdp_socket) return _LOGGER.error( @@ -138,6 +139,8 @@ class UPNPResponderThread(threading.Thread): """Stop the server.""" # Request for server self._interrupted = True + if self._ssdp_socket: + clean_socket_close(self._ssdp_socket) self.join() def _handle_request(self, data): diff --git a/homeassistant/components/enocean/translations/it.json b/homeassistant/components/enocean/translations/it.json new file mode 100644 index 00000000000..857269a6ae1 --- /dev/null +++ b/homeassistant/components/enocean/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "Percorso del dongle non valido", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "error": { + "invalid_dongle_path": "Nessun dongle valido trovato per questo percorso" + }, + "flow_title": "Configurazione di ENOcean", + "step": { + "detect": { + "data": { + "path": "Percorso dongle USB" + }, + "title": "Seleziona il percorso verso il tuo dongle ENOcean" + }, + "manual": { + "data": { + "path": "Percorso dongle USB" + }, + "title": "Inserisci il percorso per il tuo dongle ENOcean" + } + } + }, + "title": "EnOcean" +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/lb.json b/homeassistant/components/enocean/translations/lb.json new file mode 100644 index 00000000000..58d131203b4 --- /dev/null +++ b/homeassistant/components/enocean/translations/lb.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "Ong\u00eblte Dongle Pad" + }, + "error": { + "invalid_dongle_path": "Kee g\u00ebltege Dongle an d\u00ebsem Pad fonnt" + }, + "flow_title": "ENOcean Konfiguratioun", + "step": { + "detect": { + "data": { + "path": "USB Dongle Pad" + }, + "title": "Wiel de Pad zu dengem ENOcean Dongle aus." + }, + "manual": { + "data": { + "path": "USB Dongle Pad" + }, + "title": "G\u00ebff de Pad zu dengem ENOcean Dongle an" + } + } + }, + "title": "EnOcean" +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/no.json b/homeassistant/components/enocean/translations/no.json index ef16b2a7cbf..a3fc35edcc8 100644 --- a/homeassistant/components/enocean/translations/no.json +++ b/homeassistant/components/enocean/translations/no.json @@ -1,6 +1,20 @@ { "config": { + "abort": { + "invalid_dongle_path": "Ugyldig donglesti", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "invalid_dongle_path": "Ingen gyldig dongle funnet for denne banen" + }, + "flow_title": "ENOcean oppsett", "step": { + "detect": { + "data": { + "path": "USB-donglebane" + }, + "title": "Velg banen til din ENOcean dongle" + }, "manual": { "data": { "path": "USB-donglebane" diff --git a/homeassistant/components/enocean/translations/pl.json b/homeassistant/components/enocean/translations/pl.json index e4a7a36a3a2..5f770418fef 100644 --- a/homeassistant/components/enocean/translations/pl.json +++ b/homeassistant/components/enocean/translations/pl.json @@ -1,12 +1,27 @@ { "config": { + "abort": { + "invalid_dongle_path": "Niepoprawna \u015bcie\u017cka urz\u0105dzenia", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "invalid_dongle_path": "Nie znaleziono urz\u0105dzenia pod wskazan\u0105 \u015bcie\u017ck\u0105" + }, "flow_title": "Konfiguracja ENOcean", "step": { "detect": { "data": { "path": "\u015acie\u017cka urz\u0105dzenia USB" - } + }, + "title": "Wybierz \u015bcie\u017ck\u0119 urz\u0105dzenia ENOcean" + }, + "manual": { + "data": { + "path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "title": "Podaj \u015bcie\u017ck\u0119 urz\u0105dzenia ENOcean" } } - } + }, + "title": "EnOcean" } \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/pt.json b/homeassistant/components/enocean/translations/pt.json new file mode 100644 index 00000000000..a004e5cae8b --- /dev/null +++ b/homeassistant/components/enocean/translations/pt.json @@ -0,0 +1,6 @@ +{ + "config": { + "flow_title": "Configura\u00e7\u00e3o ENOcean" + }, + "title": "EnOcean" +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/sl.json b/homeassistant/components/enocean/translations/sl.json new file mode 100644 index 00000000000..da0df2714c9 --- /dev/null +++ b/homeassistant/components/enocean/translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "Neveljavna pot klju\u010da", + "single_instance_allowed": "\u017de konfigurirano. Mo\u017ena je samo ena konfiguracija." + }, + "error": { + "invalid_dongle_path": "Za to pot ni bilo mogo\u010de najti veljavnega klju\u010da" + }, + "flow_title": "ENOcean nastavitev", + "step": { + "detect": { + "data": { + "path": "Pot do USB klju\u010da" + }, + "title": "Izberite pot do va\u0161ega ENOcean klju\u010da" + }, + "manual": { + "data": { + "path": "Pot do USB klju\u010da" + }, + "title": "Vnesite pot do va\u0161ega ENOcean klju\u010dka" + } + } + }, + "title": "EnOcean" +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/no.json b/homeassistant/components/esphome/translations/no.json index 200481cc7b4..3c2dafff34d 100644 --- a/homeassistant/components/esphome/translations/no.json +++ b/homeassistant/components/esphome/translations/no.json @@ -24,7 +24,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "description": "Vennligst fyll inn tilkoblingsinnstillinger for din [ESPHome](https://esphomelib.com/) node." } diff --git a/homeassistant/components/esphome/translations/tr.json b/homeassistant/components/esphome/translations/tr.json new file mode 100644 index 00000000000..15028c4fe65 --- /dev/null +++ b/homeassistant/components/esphome/translations/tr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "discovery_confirm": { + "title": "Ke\u015ffedilen ESPHome d\u00fc\u011f\u00fcm\u00fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index e436268db63..f429f5bd2f1 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -107,7 +107,7 @@ def _dt_aware_to_naive(dt_aware: dt) -> dt: return dt_naive.replace(microsecond=0) -def convert_until(status_dict: dict, until_key: str) -> str: +def convert_until(status_dict: dict, until_key: str) -> None: """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" if until_key in status_dict: # only present for certain modes dt_utc_naive = dt_util.parse_datetime(status_dict[until_key]) @@ -614,13 +614,14 @@ class EvoChild(EvoDevice): @property def current_temperature(self) -> Optional[float]: """Return the current temperature of a Zone.""" - if not self._evo_device.temperatureStatus["isAvailable"]: - return None - - if self._evo_broker.temps: + if ( + self._evo_broker.temps + and self._evo_broker.temps[self._evo_device.zoneId] != 128 + ): return self._evo_broker.temps[self._evo_device.zoneId] - return self._evo_device.temperatureStatus["temperature"] + if self._evo_device.temperatureStatus["isAvailable"]: + return self._evo_device.temperatureStatus["temperature"] @property def setpoints(self) -> Dict[str, Any]: diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index c6edb4aa1dc..e99cae5e22e 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -5,9 +5,6 @@ from typing import List, Optional from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_OFF, HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF, @@ -200,19 +197,6 @@ class EvoZone(EvoChild, EvoClimateEntity): is_off = self.target_temperature <= self.min_temp return HVAC_MODE_OFF if is_off else HVAC_MODE_HEAT - @property - def hvac_action(self) -> Optional[str]: - """Return the current running hvac operation if supported.""" - if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF: - return CURRENT_HVAC_OFF - if self.target_temperature <= self.min_temp: - return CURRENT_HVAC_OFF - if not self._evo_device.temperatureStatus["isAvailable"]: - return None - if self.target_temperature <= self.current_temperature: - return CURRENT_HVAC_IDLE - return CURRENT_HVAC_HEAT - @property def target_temperature(self) -> float: """Return the target temperature of a Zone.""" diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 44e55b37faa..4233953ca8c 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -235,7 +235,7 @@ class FibaroController: scenes = self._client.scenes.list() self._scene_map = {} for device in scenes: - if not device.visible: + if "visible" in device and not device.visible: continue device.fibaro_controller = self if device.roomID == 0: @@ -292,7 +292,11 @@ class FibaroController: # otherwise add the first visible device in the group # which is a hack, but solves a problem with FGT having # hidden compatibility devices before the real device - if last_climate_parent != device.parentId and device.visible: + if ( + last_climate_parent != device.parentId + and "visible" in device + and device.visible + ): self.fibaro_devices[dtype].append(device) last_climate_parent = device.parentId _LOGGER.debug( diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py new file mode 100644 index 00000000000..b64a88cbf57 --- /dev/null +++ b/homeassistant/components/firmata/__init__.py @@ -0,0 +1,191 @@ +"""Support for Arduino-compatible Microcontrollers through Firmata.""" +import asyncio +from copy import copy +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .board import FirmataBoard +from .const import ( + CONF_ARDUINO_INSTANCE_ID, + CONF_ARDUINO_WAIT, + CONF_BINARY_SENSORS, + CONF_INITIAL_STATE, + CONF_NEGATE_STATE, + CONF_PIN, + CONF_PIN_MODE, + CONF_SAMPLING_INTERVAL, + CONF_SERIAL_BAUD_RATE, + CONF_SERIAL_PORT, + CONF_SLEEP_TUNE, + CONF_SWITCHES, + DOMAIN, + FIRMATA_MANUFACTURER, + PIN_MODE_INPUT, + PIN_MODE_OUTPUT, + PIN_MODE_PULLUP, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_CONFIGS = "board_configs" + +ANALOG_PIN_SCHEMA = vol.All(cv.string, vol.Match(r"^A[0-9]+$")) + +SWITCH_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_PIN): vol.Any(cv.positive_int, ANALOG_PIN_SCHEMA), + # will be analog mode in future too + vol.Required(CONF_PIN_MODE): PIN_MODE_OUTPUT, + vol.Optional(CONF_INITIAL_STATE, default=False): cv.boolean, + vol.Optional(CONF_NEGATE_STATE, default=False): cv.boolean, + }, + required=True, +) + +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_PIN): vol.Any(cv.positive_int, ANALOG_PIN_SCHEMA), + # will be analog mode in future too + vol.Required(CONF_PIN_MODE): vol.Any(PIN_MODE_INPUT, PIN_MODE_PULLUP), + vol.Optional(CONF_NEGATE_STATE, default=False): cv.boolean, + }, + required=True, +) + +BOARD_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_PORT): cv.string, + vol.Optional(CONF_SERIAL_BAUD_RATE): cv.positive_int, + vol.Optional(CONF_ARDUINO_INSTANCE_ID): cv.positive_int, + vol.Optional(CONF_ARDUINO_WAIT): cv.positive_int, + vol.Optional(CONF_SLEEP_TUNE): vol.All( + vol.Coerce(float), vol.Range(min=0.0001) + ), + vol.Optional(CONF_SAMPLING_INTERVAL): cv.positive_int, + vol.Optional(CONF_SWITCHES): [SWITCH_SCHEMA], + vol.Optional(CONF_BINARY_SENSORS): [BINARY_SENSOR_SCHEMA], + }, + required=True, +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [BOARD_CONFIG_SCHEMA])}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the Firmata domain.""" + # Delete specific entries that no longer exist in the config + if hass.config_entries.async_entries(DOMAIN): + for entry in hass.config_entries.async_entries(DOMAIN): + remove = True + for board in config[DOMAIN]: + if entry.data[CONF_SERIAL_PORT] == board[CONF_SERIAL_PORT]: + remove = False + break + if remove: + await hass.config_entries.async_remove(entry.entry_id) + + # Setup new entries and update old entries + for board in config[DOMAIN]: + firmata_config = copy(board) + existing_entry = False + for entry in hass.config_entries.async_entries(DOMAIN): + if board[CONF_SERIAL_PORT] == entry.data[CONF_SERIAL_PORT]: + existing_entry = True + firmata_config[CONF_NAME] = entry.data[CONF_NAME] + hass.config_entries.async_update_entry(entry, data=firmata_config) + break + if not existing_entry: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=firmata_config, + ) + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Set up a Firmata board for a config entry.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + _LOGGER.debug( + "Setting up Firmata id %s, name %s, config %s", + config_entry.entry_id, + config_entry.data[CONF_NAME], + config_entry.data, + ) + + board = FirmataBoard(config_entry.data) + + if not await board.async_setup(): + return False + + hass.data[DOMAIN][config_entry.entry_id] = board + + async def handle_shutdown(event) -> None: + """Handle shutdown of board when Home Assistant shuts down.""" + # Ensure board was not already removed previously before shutdown + if config_entry.entry_id in hass.data[DOMAIN]: + await board.async_reset() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_shutdown) + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={}, + identifiers={(DOMAIN, board.name)}, + manufacturer=FIRMATA_MANUFACTURER, + name=board.name, + sw_version=board.firmware_version, + ) + + if CONF_BINARY_SENSORS in config_entry.data: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") + ) + if CONF_SWITCHES in config_entry.data: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "switch") + ) + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> None: + """Shutdown and close a Firmata board for a config entry.""" + _LOGGER.debug("Closing Firmata board %s", config_entry.data[CONF_NAME]) + + unload_entries = [] + if CONF_BINARY_SENSORS in config_entry.data: + unload_entries.append( + hass.config_entries.async_forward_entry_unload( + config_entry, "binary_sensor" + ) + ) + if CONF_SWITCHES in config_entry.data: + unload_entries.append( + hass.config_entries.async_forward_entry_unload(config_entry, "switch") + ) + results = [] + if unload_entries: + results = await asyncio.gather(*unload_entries) + results.append(await hass.data[DOMAIN].pop(config_entry.entry_id).async_reset()) + + return False not in results diff --git a/homeassistant/components/firmata/binary_sensor.py b/homeassistant/components/firmata/binary_sensor.py new file mode 100644 index 00000000000..4576b8dc69e --- /dev/null +++ b/homeassistant/components/firmata/binary_sensor.py @@ -0,0 +1,59 @@ +"""Support for Firmata binary sensor input.""" + +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .const import CONF_NEGATE_STATE, CONF_PIN, CONF_PIN_MODE, DOMAIN +from .entity import FirmataPinEntity +from .pin import FirmataBinaryDigitalInput, FirmataPinUsedException + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Firmata binary sensors.""" + new_entities = [] + + board = hass.data[DOMAIN][config_entry.entry_id] + for binary_sensor in board.binary_sensors: + pin = binary_sensor[CONF_PIN] + pin_mode = binary_sensor[CONF_PIN_MODE] + negate = binary_sensor[CONF_NEGATE_STATE] + api = FirmataBinaryDigitalInput(board, pin, pin_mode, negate) + try: + api.setup() + except FirmataPinUsedException: + _LOGGER.error( + "Could not setup binary sensor on pin %s since pin already in use.", + binary_sensor[CONF_PIN], + ) + continue + name = binary_sensor[CONF_NAME] + binary_sensor_entity = FirmataBinarySensor(api, config_entry, name, pin) + new_entities.append(binary_sensor_entity) + + if new_entities: + async_add_entities(new_entities) + + +class FirmataBinarySensor(FirmataPinEntity, BinarySensorEntity): + """Representation of a binary sensor on a Firmata board.""" + + async def async_added_to_hass(self) -> None: + """Set up a binary sensor.""" + await self._api.start_pin(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop reporting a binary sensor.""" + await self._api.stop_pin() + + @property + def is_on(self) -> bool: + """Return true if binary sensor is on.""" + return self._api.is_on diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py new file mode 100644 index 00000000000..bae30014d63 --- /dev/null +++ b/homeassistant/components/firmata/board.py @@ -0,0 +1,144 @@ +"""Code to handle a Firmata board.""" +import logging +from typing import Union + +from pymata_express.pymata_express import PymataExpress +from pymata_express.pymata_express_serial import serial + +from homeassistant.const import CONF_NAME + +from .const import ( + CONF_ARDUINO_INSTANCE_ID, + CONF_ARDUINO_WAIT, + CONF_BINARY_SENSORS, + CONF_SAMPLING_INTERVAL, + CONF_SERIAL_BAUD_RATE, + CONF_SERIAL_PORT, + CONF_SLEEP_TUNE, + CONF_SWITCHES, +) + +_LOGGER = logging.getLogger(__name__) + +FirmataPinType = Union[int, str] + + +class FirmataBoard: + """Manages a single Firmata board.""" + + def __init__(self, config: dict): + """Initialize the board.""" + self.config = config + self.api = None + self.firmware_version = None + self.protocol_version = None + self.name = self.config[CONF_NAME] + self.switches = [] + self.binary_sensors = [] + self.used_pins = [] + + if CONF_SWITCHES in self.config: + self.switches = self.config[CONF_SWITCHES] + if CONF_BINARY_SENSORS in self.config: + self.binary_sensors = self.config[CONF_BINARY_SENSORS] + + async def async_setup(self, tries=0) -> bool: + """Set up a Firmata instance.""" + try: + _LOGGER.debug("Connecting to Firmata %s", self.name) + self.api = await get_board(self.config) + except RuntimeError as err: + _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) + return False + except serial.serialutil.SerialTimeoutException as err: + _LOGGER.error( + "Timeout writing to serial port for PyMata board %s: %s", self.name, err + ) + return False + except serial.serialutil.SerialException as err: + _LOGGER.error( + "Error connecting to serial port for PyMata board %s: %s", + self.name, + err, + ) + return False + + self.firmware_version = await self.api.get_firmware_version() + if not self.firmware_version: + _LOGGER.error( + "Error retrieving firmware version from Firmata board %s", self.name + ) + return False + + if CONF_SAMPLING_INTERVAL in self.config: + try: + await self.api.set_sampling_interval( + self.config[CONF_SAMPLING_INTERVAL] + ) + except RuntimeError as err: + _LOGGER.error( + "Error setting sampling interval for PyMata \ +board %s: %s", + self.name, + err, + ) + return False + + _LOGGER.debug("Firmata connection successful for %s", self.name) + return True + + async def async_reset(self) -> bool: + """Reset the board to default state.""" + _LOGGER.debug("Shutting down board %s", self.name) + # If the board was never setup, continue. + if self.api is None: + return True + + await self.api.shutdown() + self.api = None + + return True + + def mark_pin_used(self, pin: FirmataPinType) -> bool: + """Test if a pin is used already on the board or mark as used.""" + if pin in self.used_pins: + return False + self.used_pins.append(pin) + return True + + def get_pin_type(self, pin: FirmataPinType) -> tuple: + """Return the type and Firmata location of a pin on the board.""" + if isinstance(pin, str): + pin_type = "analog" + firmata_pin = int(pin[1:]) + firmata_pin += self.api.first_analog_pin + else: + pin_type = "digital" + firmata_pin = pin + return (pin_type, firmata_pin) + + +async def get_board(data: dict) -> PymataExpress: + """Create a Pymata board object.""" + board_data = {} + + if CONF_SERIAL_PORT in data: + board_data["com_port"] = data[CONF_SERIAL_PORT] + if CONF_SERIAL_BAUD_RATE in data: + board_data["baud_rate"] = data[CONF_SERIAL_BAUD_RATE] + if CONF_ARDUINO_INSTANCE_ID in data: + board_data["arduino_instance_id"] = data[CONF_ARDUINO_INSTANCE_ID] + + if CONF_ARDUINO_WAIT in data: + board_data["arduino_wait"] = data[CONF_ARDUINO_WAIT] + if CONF_SLEEP_TUNE in data: + board_data["sleep_tune"] = data[CONF_SLEEP_TUNE] + + board_data["autostart"] = False + board_data["shutdown_on_exception"] = True + board_data["close_loop_on_shutdown"] = False + + board = PymataExpress(**board_data) + + await board.start_aio() + return board diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py new file mode 100644 index 00000000000..a86d97e9e2e --- /dev/null +++ b/homeassistant/components/firmata/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow to configure firmata component.""" + +import logging + +from pymata_express.pymata_express_serial import serial + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME + +from .board import get_board +from .const import CONF_SERIAL_PORT, DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a firmata config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_import(self, import_config: dict): + """Import a firmata board as a config entry. + + This flow is triggered by `async_setup` for configured boards. + + This will execute for any board that does not have a + config entry yet (based on entry_id). It validates a connection + and then adds the entry. + """ + name = f"serial-{import_config[CONF_SERIAL_PORT]}" + import_config[CONF_NAME] = name + + # Connect to the board to verify connection and then shutdown + # If either fail then we cannot continue + _LOGGER.debug("Connecting to Firmata board %s to test connection", name) + try: + api = await get_board(import_config) + await api.shutdown() + except RuntimeError as err: + _LOGGER.error("Error connecting to PyMata board %s: %s", name, err) + return self.async_abort(reason="cannot_connect") + except serial.serialutil.SerialTimeoutException as err: + _LOGGER.error( + "Timeout writing to serial port for PyMata board %s: %s", name, err + ) + return self.async_abort(reason="cannot_connect") + except serial.serialutil.SerialException as err: + _LOGGER.error( + "Error connecting to serial port for PyMata board %s: %s", name, err + ) + return self.async_abort(reason="cannot_connect") + _LOGGER.debug("Connection test to Firmata board %s successful", name) + + return self.async_create_entry( + title=import_config[CONF_NAME], data=import_config + ) diff --git a/homeassistant/components/firmata/const.py b/homeassistant/components/firmata/const.py new file mode 100644 index 00000000000..1ad3cbb8423 --- /dev/null +++ b/homeassistant/components/firmata/const.py @@ -0,0 +1,24 @@ +"""Constants for the Firmata component.""" +import logging + +LOGGER = logging.getLogger(__package__) + +CONF_ARDUINO_INSTANCE_ID = "arduino_instance_id" +CONF_ARDUINO_WAIT = "arduino_wait" +CONF_BINARY_SENSORS = "binary_sensors" +CONF_INITIAL_STATE = "initial" +CONF_NAME = "name" +CONF_NEGATE_STATE = "negate" +CONF_PIN = "pin" +CONF_PINS = "pins" +CONF_PIN_MODE = "pin_mode" +PIN_MODE_OUTPUT = "OUTPUT" +PIN_MODE_INPUT = "INPUT" +PIN_MODE_PULLUP = "PULLUP" +CONF_SAMPLING_INTERVAL = "sampling_interval" +CONF_SERIAL_BAUD_RATE = "serial_baud_rate" +CONF_SERIAL_PORT = "serial_port" +CONF_SLEEP_TUNE = "sleep_tune" +CONF_SWITCHES = "switches" +DOMAIN = "firmata" +FIRMATA_MANUFACTURER = "Firmata" diff --git a/homeassistant/components/firmata/entity.py b/homeassistant/components/firmata/entity.py new file mode 100644 index 00000000000..50ab58b9046 --- /dev/null +++ b/homeassistant/components/firmata/entity.py @@ -0,0 +1,60 @@ +"""Entity for Firmata devices.""" +from typing import Type + +from homeassistant.config_entries import ConfigEntry + +from .board import FirmataPinType +from .const import DOMAIN, FIRMATA_MANUFACTURER +from .pin import FirmataBoardPin + + +class FirmataEntity: + """Representation of a Firmata entity.""" + + def __init__(self, api): + """Initialize the entity.""" + self._api = api + + @property + def device_info(self) -> dict: + """Return device info.""" + return { + "connections": {}, + "identifiers": {(DOMAIN, self._api.board.name)}, + "manufacturer": FIRMATA_MANUFACTURER, + "name": self._api.board.name, + "sw_version": self._api.board.firmware_version, + } + + +class FirmataPinEntity(FirmataEntity): + """Representation of a Firmata pin entity.""" + + def __init__( + self, + api: Type[FirmataBoardPin], + config_entry: ConfigEntry, + name: str, + pin: FirmataPinType, + ): + """Initialize the pin entity.""" + super().__init__(api) + self._name = name + + location = (config_entry.entry_id, "pin", pin) + self._unique_id = "_".join(str(i) for i in location) + + @property + def name(self) -> str: + """Get the name of the pin.""" + return self._name + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return self._unique_id diff --git a/homeassistant/components/firmata/manifest.json b/homeassistant/components/firmata/manifest.json new file mode 100644 index 00000000000..d894c0a440b --- /dev/null +++ b/homeassistant/components/firmata/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "firmata", + "name": "Firmata", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/firmata", + "requirements": [ + "pymata-express==1.13" + ], + "codeowners": [ + "@DaAwesomeP" + ] +} \ No newline at end of file diff --git a/homeassistant/components/firmata/pin.py b/homeassistant/components/firmata/pin.py new file mode 100644 index 00000000000..644986fb66c --- /dev/null +++ b/homeassistant/components/firmata/pin.py @@ -0,0 +1,153 @@ +"""Code to handle pins on a Firmata board.""" +import logging +from typing import Callable + +from homeassistant.core import callback + +from .board import FirmataBoard, FirmataPinType +from .const import PIN_MODE_INPUT, PIN_MODE_PULLUP + +_LOGGER = logging.getLogger(__name__) + + +class FirmataPinUsedException(Exception): + """Represents an exception when a pin is already in use.""" + + +class FirmataBoardPin: + """Manages a single Firmata board pin.""" + + def __init__(self, board: FirmataBoard, pin: FirmataPinType, pin_mode: str): + """Initialize the pin.""" + self.board = board + self._pin = pin + self._pin_mode = pin_mode + self._pin_type, self._firmata_pin = self.board.get_pin_type(self._pin) + self._state = None + + def setup(self): + """Set up a pin and make sure it is valid.""" + if not self.board.mark_pin_used(self._pin): + raise FirmataPinUsedException(f"Pin {self._pin} already used!") + + +class FirmataBinaryDigitalOutput(FirmataBoardPin): + """Representation of a Firmata Digital Output Pin.""" + + def __init__( + self, + board: FirmataBoard, + pin: FirmataPinType, + pin_mode: str, + initial: bool, + negate: bool, + ): + """Initialize the digital output pin.""" + self._initial = initial + self._negate = negate + super().__init__(board, pin, pin_mode) + + async def start_pin(self) -> None: + """Set initial state on a pin.""" + _LOGGER.debug( + "Setting initial state for digital output pin %s on board %s", + self._pin, + self.board.name, + ) + api = self.board.api + # Only PIN_MODE_OUTPUT mode is supported as binary digital output + await api.set_pin_mode_digital_output(self._firmata_pin) + + if self._initial: + new_pin_state = not self._negate + else: + new_pin_state = self._negate + await api.digital_pin_write(self._firmata_pin, int(new_pin_state)) + self._state = self._initial + + @property + def is_on(self) -> bool: + """Return true if digital output is on.""" + return self._state + + async def turn_on(self) -> None: + """Turn on digital output.""" + _LOGGER.debug("Turning digital output on pin %s on", self._pin) + new_pin_state = not self._negate + await self.board.api.digital_pin_write(self._firmata_pin, int(new_pin_state)) + self._state = True + + async def turn_off(self) -> None: + """Turn off digital output.""" + _LOGGER.debug("Turning digital output on pin %s off", self._pin) + new_pin_state = self._negate + await self.board.api.digital_pin_write(self._firmata_pin, int(new_pin_state)) + self._state = False + + +class FirmataBinaryDigitalInput(FirmataBoardPin): + """Representation of a Firmata Digital Input Pin.""" + + def __init__( + self, board: FirmataBoard, pin: FirmataPinType, pin_mode: str, negate: bool + ): + """Initialize the digital input pin.""" + self._negate = negate + self._forward_callback = None + super().__init__(board, pin, pin_mode) + + async def start_pin(self, forward_callback: Callable[[], None]) -> None: + """Get initial state and start reporting a pin.""" + _LOGGER.debug( + "Starting reporting updates for input pin %s on board %s", + self._pin, + self.board.name, + ) + self._forward_callback = forward_callback + api = self.board.api + if self._pin_mode == PIN_MODE_INPUT: + await api.set_pin_mode_digital_input(self._pin, self.latch_callback) + elif self._pin_mode == PIN_MODE_PULLUP: + await api.set_pin_mode_digital_input_pullup(self._pin, self.latch_callback) + + new_state = bool((await self.board.api.digital_read(self._firmata_pin))[0]) + if self._negate: + new_state = not new_state + self._state = new_state + + await api.enable_digital_reporting(self._pin) + self._forward_callback() + + async def stop_pin(self) -> None: + """Stop reporting digital input pin.""" + _LOGGER.debug( + "Stopping reporting updates for digital input pin %s on board %s", + self._pin, + self.board.name, + ) + api = self.board.api + await api.disable_digital_reporting(self._pin) + + @property + def is_on(self) -> bool: + """Return true if digital input is on.""" + return self._state + + @callback + async def latch_callback(self, data: list) -> None: + """Update pin state on callback.""" + if data[1] != self._firmata_pin: + return + _LOGGER.debug( + "Received latch %d for digital input pin %d on board %s", + data[2], + self._firmata_pin, + self.board.name, + ) + new_state = bool(data[2]) + if self._negate: + new_state = not new_state + if self._state == new_state: + return + self._state = new_state + self._forward_callback() diff --git a/homeassistant/components/firmata/strings.json b/homeassistant/components/firmata/strings.json new file mode 100644 index 00000000000..68d7ae8c041 --- /dev/null +++ b/homeassistant/components/firmata/strings.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "cannot_connect": "Cannot connect to Firmata board during setup" + }, + "step": {} + } +} diff --git a/homeassistant/components/firmata/switch.py b/homeassistant/components/firmata/switch.py new file mode 100644 index 00000000000..ab67a6d6840 --- /dev/null +++ b/homeassistant/components/firmata/switch.py @@ -0,0 +1,75 @@ +"""Support for Firmata switch output.""" + +import logging + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_INITIAL_STATE, + CONF_NEGATE_STATE, + CONF_PIN, + CONF_PIN_MODE, + DOMAIN, +) +from .entity import FirmataPinEntity +from .pin import FirmataBinaryDigitalOutput, FirmataPinUsedException + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Firmata switches.""" + new_entities = [] + + board = hass.data[DOMAIN][config_entry.entry_id] + for switch in board.switches: + pin = switch[CONF_PIN] + pin_mode = switch[CONF_PIN_MODE] + initial = switch[CONF_INITIAL_STATE] + negate = switch[CONF_NEGATE_STATE] + api = FirmataBinaryDigitalOutput(board, pin, pin_mode, initial, negate) + try: + api.setup() + except FirmataPinUsedException: + _LOGGER.error( + "Could not setup switch on pin %s since pin already in use.", + switch[CONF_PIN], + ) + continue + name = switch[CONF_NAME] + switch_entity = FirmataSwitch(api, config_entry, name, pin) + new_entities.append(switch_entity) + + if new_entities: + async_add_entities(new_entities) + + +class FirmataSwitch(FirmataPinEntity, SwitchEntity): + """Representation of a switch on a Firmata board.""" + + async def async_added_to_hass(self) -> None: + """Set up a switch.""" + await self._api.start_pin() + self.async_write_ha_state() + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self._api.is_on + + async def async_turn_on(self, **kwargs) -> None: + """Turn on switch.""" + _LOGGER.debug("Turning switch %s on", self._name) + await self._api.turn_on() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off switch.""" + _LOGGER.debug("Turning switch %s off", self._name) + await self._api.turn_off() + self.async_write_ha_state() diff --git a/homeassistant/components/firmata/translations/ca.json b/homeassistant/components/firmata/translations/ca.json new file mode 100644 index 00000000000..29a04d50b3e --- /dev/null +++ b/homeassistant/components/firmata/translations/ca.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "No s'ha pogut connectar a la placa Frimata durant la configuraci\u00f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/en.json b/homeassistant/components/firmata/translations/en.json new file mode 100644 index 00000000000..39ea716d975 --- /dev/null +++ b/homeassistant/components/firmata/translations/en.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "Cannot connect to Firmata board during setup" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/es.json b/homeassistant/components/firmata/translations/es.json new file mode 100644 index 00000000000..3cd5b23ceb6 --- /dev/null +++ b/homeassistant/components/firmata/translations/es.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "No se ha podido conectar a la placa Firmata durante la configuraci\u00f3n" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/it.json b/homeassistant/components/firmata/translations/it.json new file mode 100644 index 00000000000..79c4a093140 --- /dev/null +++ b/homeassistant/components/firmata/translations/it.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "cannot_connect": "Impossibile connettersi alla scheda Firmata durante la configurazione" + }, + "step": { + "one": "uno", + "other": "altri" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/ko.json b/homeassistant/components/firmata/translations/ko.json new file mode 100644 index 00000000000..753a5851811 --- /dev/null +++ b/homeassistant/components/firmata/translations/ko.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\uc124\uce58\ud558\ub294 \ub3d9\uc548 Firmata \ubcf4\ub4dc\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/no.json b/homeassistant/components/firmata/translations/no.json new file mode 100644 index 00000000000..e1e5c8f1ea4 --- /dev/null +++ b/homeassistant/components/firmata/translations/no.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "Kan ikke koble til Firmata Board under installasjonen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/pt.json b/homeassistant/components/firmata/translations/pt.json new file mode 100644 index 00000000000..785f8887678 --- /dev/null +++ b/homeassistant/components/firmata/translations/pt.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "cannot_connect": "N\u0101o foi poss\u00edvel conectar ao board do Firmata durante a configura\u00e7\u00e3o" + }, + "step": { + "one": "Um", + "other": "Outro" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/ru.json b/homeassistant/components/firmata/translations/ru.json new file mode 100644 index 00000000000..64737774a2d --- /dev/null +++ b/homeassistant/components/firmata/translations/ru.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u043b\u0430\u0442\u0435 Firmata \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/zh-Hant.json b/homeassistant/components/firmata/translations/zh-Hant.json new file mode 100644 index 00000000000..d86ad56653c --- /dev/null +++ b/homeassistant/components/firmata/translations/zh-Hant.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u65bc\u8a2d\u5b9a\u671f\u9593\uff0c\u7121\u6cd5\u9023\u7dda\u81f3 Firmata \u677f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/nl.json b/homeassistant/components/flick_electric/translations/nl.json new file mode 100644 index 00000000000..5f7433d97db --- /dev/null +++ b/homeassistant/components/flick_electric/translations/nl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Wachtwoord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index 4f05b93ea22..a7bb9fbd3c8 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -6,6 +6,15 @@ PLATFORMS = ["sensor"] DEFAULT_NAME = "Flume Sensor" FLUME_TYPE_SENSOR = 2 +FLUME_QUERIES_SENSOR = { + "current_interval": {"friendly_name": "Current", "unit_of_measurement": "gal/m"}, + "month_to_date": {"friendly_name": "Current Month", "unit_of_measurement": "gal"}, + "week_to_date": {"friendly_name": "Current Week", "unit_of_measurement": "gal"}, + "today": {"friendly_name": "Current Day", "unit_of_measurement": "gal"}, + "last_60_min": {"friendly_name": "60 Minutes", "unit_of_measurement": "gal/h"}, + "last_24_hrs": {"friendly_name": "24 Hours", "unit_of_measurement": "gal/d"}, + "last_30_days": {"friendly_name": "30 Days", "unit_of_measurement": "gal/mo"}, +} FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" @@ -20,3 +29,4 @@ KEY_DEVICE_TYPE = "type" KEY_DEVICE_ID = "id" KEY_DEVICE_LOCATION = "location" KEY_DEVICE_LOCATION_NAME = "name" +KEY_DEVICE_LOCATION_TIMEZONE = "tz" diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index f801eedf73b..3698df3c269 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -2,7 +2,7 @@ "domain": "flume", "name": "Flume", "documentation": "https://www.home-assistant.io/integrations/flume/", - "requirements": ["pyflume==0.4.0"], + "requirements": ["pyflume==0.5.5"], "dependencies": [], "codeowners": ["@ChrisMandich", "@bdraco"], "config_flow": true diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 9ec54ca1a8c..596bf5a0b8b 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -1,6 +1,7 @@ """Sensor for displaying the number of result from Flume.""" from datetime import timedelta import logging +from numbers import Number from pyflume import FlumeData import voluptuous as vol @@ -24,10 +25,12 @@ from .const import ( FLUME_AUTH, FLUME_DEVICES, FLUME_HTTP_SESSION, + FLUME_QUERIES_SENSOR, FLUME_TYPE_SENSOR, KEY_DEVICE_ID, KEY_DEVICE_LOCATION, KEY_DEVICE_LOCATION_NAME, + KEY_DEVICE_LOCATION_TIMEZONE, KEY_DEVICE_TYPE, ) @@ -49,7 +52,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Import the platform into a config entry.""" - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=config @@ -59,7 +61,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Flume sensor.""" - flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] flume_auth = flume_domain_data[FLUME_AUTH] @@ -76,17 +77,28 @@ async def async_setup_entry(hass, config_entry, async_add_entities): device_id = device[KEY_DEVICE_ID] device_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME] + device_timezone = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_TIMEZONE] device_friendly_name = f"{name} {device_name}" flume_device = FlumeData( flume_auth, device_id, + device_timezone, SCAN_INTERVAL, update_on_init=False, http_session=http_session, ) - flume_entity_list.append( - FlumeSensor(flume_device, device_friendly_name, device_id) - ) + + flume_data = FlumeSensorData(flume_device) + + for flume_query_sensor in FLUME_QUERIES_SENSOR.items(): + flume_entity_list.append( + FlumeSensor( + flume_data, + flume_query_sensor, + f"{device_friendly_name} {flume_query_sensor[1]['friendly_name']}", + device_id, + ) + ) if flume_entity_list: async_add_entities(flume_entity_list) @@ -95,13 +107,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FlumeSensor(Entity): """Representation of the Flume sensor.""" - def __init__(self, flume_device, name, device_id): + def __init__(self, flume_data, flume_query_sensor, name, device_id): """Initialize the Flume sensor.""" - self._flume_device = flume_device + self._flume_data = flume_data + self._flume_query_sensor = flume_query_sensor self._name = name self._device_id = device_id self._undo_track_sensor = None - self._available = False + self._available = self._flume_data.available self._state = None @property @@ -128,7 +141,7 @@ class FlumeSensor(Entity): def unit_of_measurement(self): """Return the unit the value is expressed in.""" # This is in gallons per SCAN_INTERVAL - return "gal/m" + return self._flume_query_sensor[1]["unit_of_measurement"] @property def available(self): @@ -137,26 +150,57 @@ class FlumeSensor(Entity): @property def unique_id(self): - """Device unique ID.""" - return self._device_id + """Flume query and Device unique ID.""" + return f"{self._flume_query_sensor[0]}_{self._device_id}" - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data and updates the states.""" - _LOGGER.debug("Updating flume sensor: %s", self._name) - try: - self._flume_device.update_force() - except Exception as ex: # pylint: disable=broad-except - if self._available: - _LOGGER.error("Update of flume sensor %s failed: %s", self._name, ex) - self._available = False - return - _LOGGER.debug("Successful update of flume sensor: %s", self._name) - self._state = self._flume_device.value - self._available = True + + def format_state_value(value): + return round(value, 1) if isinstance(value, Number) else None + + self._flume_data.update() + self._state = format_state_value( + self._flume_data.flume_device.values[self._flume_query_sensor[0]] + ) + _LOGGER.debug( + "Updating sensor: '%s', value: '%s'", + self._name, + self._flume_data.flume_device.values[self._flume_query_sensor[0]], + ) + self._available = self._flume_data.available async def async_added_to_hass(self): """Request an update when added.""" # We do ask for an update with async_add_entities() # because it will update disabled entities self.async_schedule_update_ha_state() + + +class FlumeSensorData: + """Get the latest data and update the states.""" + + def __init__(self, flume_device): + """Initialize the data object.""" + self.flume_device = flume_device + self.available = True + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the Flume.""" + _LOGGER.debug("Updating Flume data") + try: + self.flume_device.update_force() + except Exception as ex: # pylint: disable=broad-except + if self.available: + _LOGGER.error("Update of Flume data failed: %s", ex) + self.available = False + return + self.available = True + _LOGGER.debug( + "Flume update details: %s", + { + "values": self.flume_device.values, + "query_payload": self.flume_device.query_payload, + }, + ) diff --git a/homeassistant/components/forked_daapd/translations/no.json b/homeassistant/components/forked_daapd/translations/no.json index 8cb7ec812ae..ac58ddf9639 100644 --- a/homeassistant/components/forked_daapd/translations/no.json +++ b/homeassistant/components/forked_daapd/translations/no.json @@ -18,7 +18,7 @@ "host": "Vert", "name": "Vennlig navn", "password": "API-passord (la st\u00e5 tomt hvis ingen passord)", - "port": "API-port" + "port": "" }, "title": "Konfigurere forked-daapd-enhet" } diff --git a/homeassistant/components/freebox/translations/no.json b/homeassistant/components/freebox/translations/no.json index 36152c9a815..0ec9bf70ecd 100644 --- a/homeassistant/components/freebox/translations/no.json +++ b/homeassistant/components/freebox/translations/no.json @@ -16,7 +16,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "title": "" } diff --git a/homeassistant/components/fritzbox/translations/es.json b/homeassistant/components/fritzbox/translations/es.json index d677acde160..69dad751e63 100644 --- a/homeassistant/components/fritzbox/translations/es.json +++ b/homeassistant/components/fritzbox/translations/es.json @@ -20,7 +20,7 @@ }, "user": { "data": { - "host": "Host o direcci\u00f3n IP", + "host": "Host", "password": "Contrase\u00f1a", "username": "Usuario" }, diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f950ca3441d..90089d50340 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -74,9 +74,16 @@ DATA_EXTRA_HTML_URL = "frontend_extra_html_url" DATA_EXTRA_HTML_URL_ES5 = "frontend_extra_html_url_es5" DATA_EXTRA_MODULE_URL = "frontend_extra_module_url" DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" + +THEMES_STORAGE_KEY = f"{DOMAIN}_theme" +THEMES_STORAGE_VERSION = 1 +THEMES_SAVE_DELAY = 60 +DATA_THEMES_STORE = "frontend_themes_store" DATA_THEMES = "frontend_themes" DATA_DEFAULT_THEME = "frontend_default_theme" +DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme" DEFAULT_THEME = "default" +VALUE_NO_THEME = "none" PRIMARY_COLOR = "primary-color" @@ -114,6 +121,7 @@ CONFIG_SCHEMA = vol.Schema( SERVICE_SET_THEME = "set_theme" SERVICE_RELOAD_THEMES = "reload_themes" +CONF_MODE = "mode" class Panel: @@ -321,17 +329,31 @@ async def async_setup(hass, config): for url in conf.get(CONF_EXTRA_JS_URL_ES5, []): add_extra_js_url(hass, url, True) - _async_setup_themes(hass, conf.get(CONF_THEMES)) + await _async_setup_themes(hass, conf.get(CONF_THEMES)) return True -@callback -def _async_setup_themes(hass, themes): +async def _async_setup_themes(hass, themes): """Set up themes data and services.""" - hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME hass.data[DATA_THEMES] = themes or {} + store = hass.data[DATA_THEMES_STORE] = hass.helpers.storage.Store( + THEMES_STORAGE_VERSION, THEMES_STORAGE_KEY + ) + + theme_data = await store.async_load() or {} + theme_name = theme_data.get(DATA_DEFAULT_THEME, DEFAULT_THEME) + dark_theme_name = theme_data.get(DATA_DEFAULT_DARK_THEME) + + if theme_name == DEFAULT_THEME or theme_name in hass.data[DATA_THEMES]: + hass.data[DATA_DEFAULT_THEME] = theme_name + else: + hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME + + if dark_theme_name == DEFAULT_THEME or dark_theme_name in hass.data[DATA_THEMES]: + hass.data[DATA_DEFAULT_DARK_THEME] = dark_theme_name + @callback def update_theme_and_fire_event(): """Update theme_color in manifest.""" @@ -348,14 +370,35 @@ def _async_setup_themes(hass, themes): @callback def set_theme(call): """Set backend-preferred theme.""" - data = call.data - name = data[CONF_NAME] - if name == DEFAULT_THEME or name in hass.data[DATA_THEMES]: - _LOGGER.info("Theme %s set as default", name) - hass.data[DATA_DEFAULT_THEME] = name - update_theme_and_fire_event() + name = call.data[CONF_NAME] + mode = call.data.get("mode", "light") + + if ( + name not in (DEFAULT_THEME, VALUE_NO_THEME) + and name not in hass.data[DATA_THEMES] + ): + _LOGGER.warning("Theme %s not found", name) + return + + light_mode = mode == "light" + + theme_key = DATA_DEFAULT_THEME if light_mode else DATA_DEFAULT_DARK_THEME + + if name == VALUE_NO_THEME: + to_set = DEFAULT_THEME if light_mode else None else: - _LOGGER.warning("Theme %s is not defined", name) + _LOGGER.info("Theme %s set as default %s theme", name, mode) + to_set = name + + hass.data[theme_key] = to_set + store.async_delay_save( + lambda: { + DATA_DEFAULT_THEME: hass.data[DATA_DEFAULT_THEME], + DATA_DEFAULT_DARK_THEME: hass.data.get(DATA_DEFAULT_DARK_THEME), + }, + THEMES_SAVE_DELAY, + ) + update_theme_and_fire_event() async def reload_themes(_): """Reload themes.""" @@ -364,6 +407,11 @@ def _async_setup_themes(hass, themes): hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME + if ( + hass.data.get(DATA_DEFAULT_DARK_THEME) + and hass.data.get(DATA_DEFAULT_DARK_THEME) not in new_themes + ): + hass.data[DATA_DEFAULT_DARK_THEME] = None update_theme_and_fire_event() service.async_register_admin_service( @@ -371,7 +419,12 @@ def _async_setup_themes(hass, themes): DOMAIN, SERVICE_SET_THEME, set_theme, - vol.Schema({vol.Required(CONF_NAME): cv.string}), + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_MODE): vol.Any("dark", "light"), + } + ), ) service.async_register_admin_service( @@ -536,6 +589,7 @@ def websocket_get_themes(hass, connection, msg): { "themes": hass.data[DATA_THEMES], "default_theme": hass.data[DATA_DEFAULT_THEME], + "default_dark_theme": hass.data.get(DATA_DEFAULT_DARK_THEME), }, ) ) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ad68adfd490..da11f574ade 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200716.0"], + "requirements": ["home-assistant-frontend==20200811.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index 489164ce7bd..31eb4d5d1ca 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -4,8 +4,11 @@ set_theme: description: Set a theme unless the client selected per-device theme. fields: name: - description: Name of a predefined theme or 'default'. + description: Name of a predefined theme, 'default' or 'none'. example: "light" + mode: + description: The mode the theme is for, either 'dark' or 'light' (default). + example: "dark" reload_themes: description: Reload themes from yaml configuration. diff --git a/homeassistant/components/garmin_connect/translations/no.json b/homeassistant/components/garmin_connect/translations/no.json index 28732d8c194..9058d46d02a 100644 --- a/homeassistant/components/garmin_connect/translations/no.json +++ b/homeassistant/components/garmin_connect/translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne kontoen er allerede konfigurert." + }, "error": { "cannot_connect": "Kunne ikke koble til, pr\u00f8v igjen.", "invalid_auth": "Ugyldig godkjenning.", diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py index d180b0a0ddf..3f6c85bdb97 100644 --- a/homeassistant/components/gios/air_quality.py +++ b/homeassistant/components/gios/air_quality.py @@ -10,7 +10,7 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_NAME -from .const import ATTR_STATION, DOMAIN, ICONS_MAP +from .const import ATTR_STATION, DEFAULT_NAME, DOMAIN, ICONS_MAP, MANUFACTURER ATTRIBUTION = "Data provided by GIOŚ" @@ -117,6 +117,16 @@ class GiosAirQuality(AirQualityEntity): """Return a unique_id for this entity.""" return self.coordinator.gios.station_id + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self.coordinator.gios.station_id)}, + "name": DEFAULT_NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } + @property def should_poll(self): """Return the polling requirement of the entity.""" diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 918b4fba2e4..117eada036b 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -8,6 +8,7 @@ DEFAULT_NAME = "GIOŚ" # Term of service GIOŚ allow downloading data no more than twice an hour. SCAN_INTERVAL = timedelta(minutes=30) DOMAIN = "gios" +MANUFACTURER = "Główny Inspektorat Ochrony Środowiska" AQI_GOOD = "dobry" AQI_MODERATE = "umiarkowany" diff --git a/homeassistant/components/glances/translations/no.json b/homeassistant/components/glances/translations/no.json index 1a03d8f1e4c..dd593c4add6 100644 --- a/homeassistant/components/glances/translations/no.json +++ b/homeassistant/components/glances/translations/no.json @@ -13,7 +13,7 @@ "host": "Vert", "name": "Navn", "password": "Passord", - "port": "Port", + "port": "", "ssl": "Bruk SSL / TLS for \u00e5 koble til Glances-systemet", "username": "Brukernavn", "verify_ssl": "Bekreft sertifiseringen av systemet", diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 05fed7621d4..a26bdbc3c8e 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -1,5 +1,4 @@ """Support for Gogogate2 garage Doors.""" -from datetime import datetime, timedelta import logging from typing import Callable, List, Optional @@ -13,13 +12,7 @@ from homeassistant.components.cover import ( CoverEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_USERNAME, - STATE_CLOSING, - STATE_OPENING, -) +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -86,8 +79,6 @@ class Gogogate2Cover(CoverEntity): self._api = data_update_coordinator.api self._unique_id = cover_unique_id(config_entry, door) self._is_available = True - self._transition_state: Optional[str] = None - self._transition_state_start: Optional[datetime] = None @property def available(self) -> bool: @@ -119,16 +110,6 @@ class Gogogate2Cover(CoverEntity): return None - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._transition_state == STATE_OPENING - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self._transition_state == STATE_CLOSING - @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" @@ -142,14 +123,10 @@ class Gogogate2Cover(CoverEntity): async def async_open_cover(self, **kwargs): """Open the door.""" await self.hass.async_add_executor_job(self._api.open_door, self._door.door_id) - self._transition_state = STATE_OPENING - self._transition_state_start = datetime.now() async def async_close_cover(self, **kwargs): """Close the door.""" await self.hass.async_add_executor_job(self._api.close_door, self._door.door_id) - self._transition_state = STATE_CLOSING - self._transition_state_start = datetime.now() @property def state_attributes(self): @@ -168,16 +145,6 @@ class Gogogate2Cover(CoverEntity): door = get_door_by_id(self._door.door_id, self._data_update_coordinator.data) - # Check if the transition state should expire. - if self._transition_state: - is_transition_state_expired = ( - datetime.now() - self._transition_state_start - ) > timedelta(seconds=60) - - if is_transition_state_expired or self._door.status != door.status: - self._transition_state = None - self._transition_state_start = None - # Set the state. self._door = door self._is_available = True diff --git a/homeassistant/components/gogogate2/translations/no.json b/homeassistant/components/gogogate2/translations/no.json index 6619f4c8fe3..8adc85e0c26 100644 --- a/homeassistant/components/gogogate2/translations/no.json +++ b/homeassistant/components/gogogate2/translations/no.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "cannot_connect": "Tilkobling mislyktes." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning" + }, "step": { "user": { "data": { @@ -7,7 +14,7 @@ "password": "Passord", "username": "Brukernavn" }, - "description": "Gi n\u00f8dvendig informasjon nedenfor. Merk: bare \"admin\" brukeren er kjent for \u00e5 fungere.", + "description": "Gi n\u00f8dvendig informasjon nedenfor.", "title": "Konfigurer GogoGate2" } } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 70cc9bd9f52..36863fd86c8 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -86,6 +86,7 @@ TRAIT_TEMPERATURE_SETTING = f"{PREFIX_TRAITS}TemperatureSetting" TRAIT_LOCKUNLOCK = f"{PREFIX_TRAITS}LockUnlock" TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed" TRAIT_MODES = f"{PREFIX_TRAITS}Modes" +TRAIT_INPUTSELECTOR = f"{PREFIX_TRAITS}InputSelector" TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose" TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume" TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm" @@ -112,6 +113,7 @@ COMMAND_THERMOSTAT_SET_MODE = f"{PREFIX_COMMANDS}ThermostatSetMode" COMMAND_LOCKUNLOCK = f"{PREFIX_COMMANDS}LockUnlock" COMMAND_FANSPEED = f"{PREFIX_COMMANDS}SetFanSpeed" COMMAND_MODES = f"{PREFIX_COMMANDS}SetModes" +COMMAND_INPUT = f"{PREFIX_COMMANDS}SetInput" COMMAND_OPENCLOSE = f"{PREFIX_COMMANDS}OpenClose" COMMAND_SET_VOLUME = f"{PREFIX_COMMANDS}setVolume" COMMAND_VOLUME_RELATIVE = f"{PREFIX_COMMANDS}volumeRelative" @@ -1151,55 +1153,89 @@ class FanSpeedTrait(_Trait): @staticmethod def supported(domain, features, device_class): """Test if state is supported.""" - if domain != fan.DOMAIN: - return False - - return features & fan.SUPPORT_SET_SPEED + if domain == fan.DOMAIN: + return features & fan.SUPPORT_SET_SPEED + if domain == climate.DOMAIN: + return features & climate.SUPPORT_FAN_MODE + return False def sync_attributes(self): """Return speed point and modes attributes for a sync request.""" - modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) + domain = self.state.domain speeds = [] - for mode in modes: - if mode not in self.speed_synonyms: - continue - speed = { - "speed_name": mode, - "speed_values": [ - {"speed_synonym": self.speed_synonyms.get(mode), "lang": "en"} - ], - } - speeds.append(speed) + reversible = False + + if domain == fan.DOMAIN: + modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) + for mode in modes: + if mode not in self.speed_synonyms: + continue + speed = { + "speed_name": mode, + "speed_values": [ + {"speed_synonym": self.speed_synonyms.get(mode), "lang": "en"} + ], + } + speeds.append(speed) + reversible = bool( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & fan.SUPPORT_DIRECTION + ) + elif domain == climate.DOMAIN: + modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) + for mode in modes: + speed = { + "speed_name": mode, + "speed_values": [{"speed_synonym": [mode], "lang": "en"}], + } + speeds.append(speed) return { "availableFanSpeeds": {"speeds": speeds, "ordered": True}, - "reversible": bool( - self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & fan.SUPPORT_DIRECTION - ), + "reversible": reversible, } def query_attributes(self): """Return speed point and modes query attributes.""" attrs = self.state.attributes + domain = self.state.domain response = {} - - speed = attrs.get(fan.ATTR_SPEED) - if speed is not None: - response["on"] = speed != fan.SPEED_OFF - response["currentFanSpeedSetting"] = speed - + if domain == climate.DOMAIN: + speed = attrs.get(climate.ATTR_FAN_MODE) + if speed is not None: + response["currentFanSpeedSetting"] = speed + if domain == fan.DOMAIN: + speed = attrs.get(fan.ATTR_SPEED) + if speed is not None: + response["on"] = speed != fan.SPEED_OFF + response["currentFanSpeedSetting"] = speed return response async def execute(self, command, data, params, challenge): """Execute an SetFanSpeed command.""" - await self.hass.services.async_call( - fan.DOMAIN, - fan.SERVICE_SET_SPEED, - {ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params["fanSpeed"]}, - blocking=True, - context=data.context, - ) + domain = self.state.domain + if domain == climate.DOMAIN: + await self.hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_FAN_MODE: params["fanSpeed"], + }, + blocking=True, + context=data.context, + ) + if domain == fan.DOMAIN: + await self.hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + { + ATTR_ENTITY_ID: self.state.entity_id, + fan.ATTR_SPEED: params["fanSpeed"], + }, + blocking=True, + context=data.context, + ) @register_trait @@ -1213,7 +1249,6 @@ class ModesTrait(_Trait): commands = [COMMAND_MODES] SYNONYMS = { - "input source": ["input source", "input", "source"], "sound mode": ["sound mode", "effects"], "option": ["option", "setting", "mode", "value"], } @@ -1230,58 +1265,51 @@ class ModesTrait(_Trait): if domain != media_player.DOMAIN: return False - return ( - features & media_player.SUPPORT_SELECT_SOURCE - or features & media_player.SUPPORT_SELECT_SOUND_MODE - ) + return features & media_player.SUPPORT_SELECT_SOUND_MODE + + def _generate(self, name, settings): + """Generate a list of modes.""" + mode = { + "name": name, + "name_values": [ + {"name_synonym": self.SYNONYMS.get(name, [name]), "lang": "en"} + ], + "settings": [], + "ordered": False, + } + for setting in settings: + mode["settings"].append( + { + "setting_name": setting, + "setting_values": [ + { + "setting_synonym": self.SYNONYMS.get(setting, [setting]), + "lang": "en", + } + ], + } + ) + return mode def sync_attributes(self): """Return mode attributes for a sync request.""" - - def _generate(name, settings): - mode = { - "name": name, - "name_values": [ - {"name_synonym": self.SYNONYMS.get(name, [name]), "lang": "en"} - ], - "settings": [], - "ordered": False, - } - for setting in settings: - mode["settings"].append( - { - "setting_name": setting, - "setting_values": [ - { - "setting_synonym": self.SYNONYMS.get( - setting, [setting] - ), - "lang": "en", - } - ], - } - ) - return mode - - attrs = self.state.attributes modes = [] - if self.state.domain == media_player.DOMAIN: - if media_player.ATTR_INPUT_SOURCE_LIST in attrs: - modes.append( - _generate( - "input source", attrs[media_player.ATTR_INPUT_SOURCE_LIST] - ) - ) - if media_player.ATTR_SOUND_MODE_LIST in attrs: - modes.append( - _generate("sound mode", attrs[media_player.ATTR_SOUND_MODE_LIST]) - ) - elif self.state.domain == input_select.DOMAIN: - modes.append(_generate("option", attrs[input_select.ATTR_OPTIONS])) - elif self.state.domain == humidifier.DOMAIN: - if humidifier.ATTR_AVAILABLE_MODES in attrs: - modes.append(_generate("mode", attrs[humidifier.ATTR_AVAILABLE_MODES])) + for domain, attr, name in ( + (media_player.DOMAIN, media_player.ATTR_SOUND_MODE_LIST, "sound mode"), + (input_select.DOMAIN, input_select.ATTR_OPTIONS, "option"), + (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), + ): + if self.state.domain != domain: + continue + + items = self.state.attributes.get(attr) + + if items is not None: + modes.append(self._generate(name, items)) + + # Shortcut since all domains are currently unique + break payload = {"availableModes": modes} @@ -1294,11 +1322,6 @@ class ModesTrait(_Trait): mode_settings = {} if self.state.domain == media_player.DOMAIN: - if media_player.ATTR_INPUT_SOURCE_LIST in attrs: - mode_settings["input source"] = attrs.get( - media_player.ATTR_INPUT_SOURCE - ) - if media_player.ATTR_SOUND_MODE_LIST in attrs: mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE) elif self.state.domain == input_select.DOMAIN: @@ -1352,21 +1375,8 @@ class ModesTrait(_Trait): ) return - requested_source = settings.get("input source") sound_mode = settings.get("sound mode") - if requested_source: - await self.hass.services.async_call( - media_player.DOMAIN, - media_player.SERVICE_SELECT_SOURCE, - { - ATTR_ENTITY_ID: self.state.entity_id, - media_player.ATTR_INPUT_SOURCE: requested_source, - }, - blocking=True, - context=data.context, - ) - if sound_mode: await self.hass.services.async_call( media_player.DOMAIN, @@ -1380,6 +1390,61 @@ class ModesTrait(_Trait): ) +@register_trait +class InputSelectorTrait(_Trait): + """Trait to set modes. + + https://developers.google.com/assistant/smarthome/traits/inputselector + """ + + name = TRAIT_INPUTSELECTOR + commands = [COMMAND_INPUT] + + SYNONYMS = {} + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain == media_player.DOMAIN and ( + features & media_player.SUPPORT_SELECT_SOURCE + ): + return True + + return False + + def sync_attributes(self): + """Return mode attributes for a sync request.""" + attrs = self.state.attributes + inputs = [ + {"key": source, "names": [{"name_synonym": [source], "lang": "en"}]} + for source in attrs.get(media_player.ATTR_INPUT_SOURCE_LIST, []) + ] + + payload = {"availableInputs": inputs, "orderedInputs": True} + + return payload + + def query_attributes(self): + """Return current modes.""" + attrs = self.state.attributes + return {"currentInput": attrs.get(media_player.ATTR_INPUT_SOURCE, "")} + + async def execute(self, command, data, params, challenge): + """Execute an SetInputSource command.""" + requested_source = params.get("newInput") + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_INPUT_SOURCE: requested_source, + }, + blocking=True, + context=data.context, + ) + + @register_trait class OpenCloseTrait(_Trait): """Trait to open and close a cover. diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 5a2c11dc481..11c78a9b271 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -39,6 +39,7 @@ from homeassistant.loader import bind_hass # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs DOMAIN = "group" +GROUP_ORDER = "group_order" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -407,16 +408,23 @@ class Group(Entity): This method must be run in the event loop. """ + hass.data.setdefault(GROUP_ORDER, 0) + group = Group( hass, name, - order=len(hass.states.async_entity_ids(DOMAIN)), + order=hass.data[GROUP_ORDER], icon=icon, user_defined=user_defined, entity_ids=entity_ids, mode=mode, ) + # Keep track of the group order without iterating + # every state in the state machine every time + # we setup a new group + hass.data[GROUP_ORDER] += 1 + group.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id or name, hass=hass ) diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 495f325eb7f..c63d80163bc 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -90,7 +90,7 @@ class GuardianBinarySensor(GuardianEntity, BinarySensorEntity): def _async_update_from_latest_data(self) -> None: """Update the entity.""" if self._kind == SENSOR_KIND_AP_INFO: - self._is_on = self._coordinators[API_WIFI_STATUS].data["ap_enabled"] + self._is_on = self._coordinators[API_WIFI_STATUS].data["station_connected"] self._attrs.update( { ATTR_CONNECTED_CLIENTS: self._coordinators[API_WIFI_STATUS].data[ diff --git a/homeassistant/components/guardian/translations/no.json b/homeassistant/components/guardian/translations/no.json index b398079cc36..fbe5f881124 100644 --- a/homeassistant/components/guardian/translations/no.json +++ b/homeassistant/components/guardian/translations/no.json @@ -9,7 +9,7 @@ "user": { "data": { "ip_address": "IP adresse", - "port": "Port" + "port": "" }, "description": "Konfigurer en lokal Elexa Guardian-enhet." }, diff --git a/homeassistant/components/guardian/translations/pl.json b/homeassistant/components/guardian/translations/pl.json index 61df3bfd913..22706a1babc 100644 --- a/homeassistant/components/guardian/translations/pl.json +++ b/homeassistant/components/guardian/translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem Guardian, spr\u00f3buj ponownie." }, "step": { diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index e5fe565bbf4..bd83307afb7 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -14,7 +14,7 @@ from .const import LOGGER DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) -class GuardianDataUpdateCoordinator(DataUpdateCoordinator): +class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): """Define an extended DataUpdateCoordinator with some Guardian goodies.""" def __init__( diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index 26368810c83..f6315b57b57 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -10,4 +10,5 @@ ATTR_ACTIVITY_LIST = "activity_list" ATTR_DEVICES_LIST = "devices_list" ATTR_LAST_ACTIVITY = "last_activity" ATTR_CURRENT_ACTIVITY = "current_activity" +ATTR_ACTIVITY_STARTING = "activity_starting" PREVIOUS_ACTIVE_ACTIVITY = "Previous Active Activity" diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 7b49321c7b0..fe2f0535308 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -30,6 +30,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ACTIVITY_POWER_OFF, ATTR_ACTIVITY_LIST, + ATTR_ACTIVITY_STARTING, ATTR_CURRENT_ACTIVITY, ATTR_DEVICES_LIST, ATTR_LAST_ACTIVITY, @@ -136,8 +137,10 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): self._name = name self.host = host self._state = None - self._current_activity = None + self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity + self._activity_starting = None + self._is_initial_update = True self._client = HarmonyClient(ip_address=host) self._config_path = out_path self.delay_secs = delay_secs @@ -172,10 +175,15 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): "connect": self.got_connected, "disconnect": self.got_disconnected, "new_activity_starting": self.new_activity, - "new_activity": None, + "new_activity": self._new_activity_finished, } self._client.callbacks = ClientCallbackType(**callbacks) + def _new_activity_finished(self, activity_info: tuple) -> None: + """Call for finished updated current activity.""" + self._activity_starting = None + self.async_write_ha_state() + async def async_added_to_hass(self): """Complete the initialization.""" await super().async_added_to_hass() @@ -252,6 +260,7 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): def device_state_attributes(self): """Add platform specific attributes.""" return { + ATTR_ACTIVITY_STARTING: self._activity_starting, ATTR_CURRENT_ACTIVITY: self._current_activity, ATTR_ACTIVITY_LIST: list_names_from_hublist( self._client.hub_config.activities @@ -288,6 +297,10 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): activity_id, activity_name = activity_info _LOGGER.debug("%s: activity reported as: %s", self._name, activity_name) self._current_activity = activity_name + if self._is_initial_update: + self._is_initial_update = False + else: + self._activity_starting = activity_name if activity_id != -1: # Save the activity so we can restore # to that activity if none is specified @@ -340,19 +353,33 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): if activity: activity_id = None + activity_name = None + if activity.isdigit() or activity == "-1": _LOGGER.debug("%s: Activity is numeric", self.name) - if self._client.get_activity_name(int(activity)): + activity_name = self._client.get_activity_name(int(activity)) + if activity_name: activity_id = activity if activity_id is None: _LOGGER.debug("%s: Find activity ID based on name", self.name) - activity_id = self._client.get_activity_id(str(activity)) + activity_name = str(activity) + activity_id = self._client.get_activity_id(activity_name) if activity_id is None: _LOGGER.error("%s: Activity %s is invalid", self.name, activity) return + if self._current_activity == activity_name: + # Automations or HomeKit may turn the device on multiple times + # when the current activity is already active which will cause + # harmony to loose state. This behavior is unexpected as turning + # the device on when its already on isn't expected to reset state. + _LOGGER.debug( + "%s: Current activity is already %s", self.name, activity_name + ) + return + try: await self._client.start_activity(activity_id) except aioexc.TimeOut: diff --git a/homeassistant/components/harmony/translations/es.json b/homeassistant/components/harmony/translations/es.json index 97656e5441d..9fbca3b97d3 100644 --- a/homeassistant/components/harmony/translations/es.json +++ b/homeassistant/components/harmony/translations/es.json @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Nombre del host o direcci\u00f3n IP", + "host": "Host", "name": "Nombre del concentrador" }, "title": "Configurar Logitech Harmony Hub" diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index f64461f70d3..69c53225d49 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -44,6 +44,7 @@ CONFIG_SCHEMA = vol.Schema( DATA_INFO = "hassio_info" DATA_HOST_INFO = "hassio_host_info" +DATA_CORE_INFO = "hassio_core_info" HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) SERVICE_ADDON_START = "addon_start" @@ -140,18 +141,6 @@ async def async_get_addon_info(hass: HomeAssistantType, addon_id: str) -> dict: return result["data"] -@callback -@bind_hass -def get_homeassistant_version(hass): - """Return latest available Home Assistant version. - - Async friendly. - """ - if DATA_INFO not in hass.data: - return None - return hass.data[DATA_INFO].get("homeassistant") - - @callback @bind_hass def get_info(hass): @@ -172,6 +161,16 @@ def get_host_info(hass): return hass.data.get(DATA_HOST_INFO) +@callback +@bind_hass +def get_core_info(hass): + """Return Home Assistant Core information from Supervisor. + + Async friendly. + """ + return hass.data.get(DATA_CORE_INFO) + + @callback @bind_hass def is_hassio(hass): @@ -301,6 +300,7 @@ async def async_setup(hass, config): try: hass.data[DATA_INFO] = await hassio.get_info() hass.data[DATA_HOST_INFO] = await hassio.get_host_info() + hass.data[DATA_CORE_INFO] = await hassio.get_core_info() except HassioAPIError as err: _LOGGER.warning("Can't read last version: %s", err) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index cce17695e30..e96ed613324 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -10,7 +10,6 @@ from homeassistant.components.http import ( CONF_SERVER_HOST, CONF_SERVER_PORT, CONF_SSL_CERTIFICATE, - DEFAULT_SERVER_HOST, ) from homeassistant.const import HTTP_BAD_REQUEST, HTTP_OK, SERVER_PORT @@ -83,6 +82,14 @@ class HassIO: """ return self.send_command("/host/info", method="get") + @_api_data + def get_core_info(self): + """Return data for Home Asssistant Core. + + This method returns a coroutine. + """ + return self.send_command("/core/info", method="get") + @_api_data def get_addon_info(self, addon): """Return data for a Add-on. @@ -142,10 +149,7 @@ class HassIO: "refresh_token": refresh_token.token, } - if ( - http_config.get(CONF_SERVER_HOST, DEFAULT_SERVER_HOST) - != DEFAULT_SERVER_HOST - ): + if http_config.get(CONF_SERVER_HOST) is not None: options["watchdog"] = False _LOGGER.warning( "Found incompatible HTTP option 'server_host'. Watchdog feature disabled" diff --git a/homeassistant/components/hisense_aehw4a1/manifest.json b/homeassistant/components/hisense_aehw4a1/manifest.json index e702e285277..00afa0d1de2 100644 --- a/homeassistant/components/hisense_aehw4a1/manifest.json +++ b/homeassistant/components/hisense_aehw4a1/manifest.json @@ -3,6 +3,6 @@ "name": "Hisense AEH-W4A1", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hisense_aehw4a1", - "requirements": ["pyaehw4a1==0.3.5"], + "requirements": ["pyaehw4a1==0.3.9"], "codeowners": ["@bannhead"] } diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index 3319ce6bee7..91b269cc520 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -4,31 +4,28 @@ import logging from hlk_sw16 import create_hlk_sw16_connection import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_SWITCHES, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from .const import ( + CONNECTION_TIMEOUT, + DEFAULT_KEEP_ALIVE_INTERVAL, + DEFAULT_PORT, + DEFAULT_RECONNECT_INTERVAL, + DOMAIN, +) + _LOGGER = logging.getLogger(__name__) DATA_DEVICE_REGISTER = "hlk_sw16_device_register" -DEFAULT_RECONNECT_INTERVAL = 10 -DEFAULT_KEEP_ALIVE_INTERVAL = 3 -CONNECTION_TIMEOUT = 10 -DEFAULT_PORT = 8080 - -DOMAIN = "hlk_sw16" +DATA_DEVICE_LISTENER = "hlk_sw16_device_listener" SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) @@ -57,84 +54,112 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): - """Set up the HLK-SW16 switch.""" - # Allow platform to specify function to register new unknown devices + """Component setup, do nothing.""" + if DOMAIN not in config: + return True - hass.data[DATA_DEVICE_REGISTER] = {} - - def add_device(device): - switches = config[DOMAIN][device][CONF_SWITCHES] - - host = config[DOMAIN][device][CONF_HOST] - port = config[DOMAIN][device][CONF_PORT] - - @callback - def disconnected(): - """Schedule reconnect after connection has been lost.""" - _LOGGER.warning("HLK-SW16 %s disconnected", device) - async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", False) - - @callback - def reconnected(): - """Schedule reconnect after connection has been lost.""" - _LOGGER.warning("HLK-SW16 %s connected", device) - async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", True) - - async def connect(): - """Set up connection and hook it into HA for reconnect/shutdown.""" - _LOGGER.info("Initiating HLK-SW16 connection to %s", device) - - client = await create_hlk_sw16_connection( - host=host, - port=port, - disconnect_callback=disconnected, - reconnect_callback=reconnected, - loop=hass.loop, - timeout=CONNECTION_TIMEOUT, - reconnect_interval=DEFAULT_RECONNECT_INTERVAL, - keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + for device_id in config[DOMAIN]: + conf = config[DOMAIN][device_id] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: conf[CONF_HOST], CONF_PORT: conf[CONF_PORT]}, ) - - hass.data[DATA_DEVICE_REGISTER][device] = client - - # Load platforms - hass.async_create_task( - async_load_platform(hass, "switch", DOMAIN, (switches, device), config) - ) - - # handle shutdown of HLK-SW16 asyncio transport - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, lambda x: client.stop() - ) - - _LOGGER.info("Connected to HLK-SW16 device: %s", device) - - hass.loop.create_task(connect()) - - for device in config[DOMAIN]: - add_device(device) + ) return True +async def async_setup_entry(hass, entry): + """Set up the HLK-SW16 switch.""" + hass.data.setdefault(DOMAIN, {}) + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + address = f"{host}:{port}" + + hass.data[DOMAIN][entry.entry_id] = {} + + @callback + def disconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning("HLK-SW16 %s disconnected", address) + async_dispatcher_send( + hass, f"hlk_sw16_device_available_{entry.entry_id}", False + ) + + @callback + def reconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning("HLK-SW16 %s connected", address) + async_dispatcher_send(hass, f"hlk_sw16_device_available_{entry.entry_id}", True) + + async def connect(): + """Set up connection and hook it into HA for reconnect/shutdown.""" + _LOGGER.info("Initiating HLK-SW16 connection to %s", address) + + client = await create_hlk_sw16_connection( + host=host, + port=port, + disconnect_callback=disconnected, + reconnect_callback=reconnected, + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL, + keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + ) + + hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] = client + + # Load entities + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "switch") + ) + + _LOGGER.info("Connected to HLK-SW16 device: %s", address) + + hass.loop.create_task(connect()) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + client = hass.data[DOMAIN][entry.entry_id].pop(DATA_DEVICE_REGISTER) + client.stop() + unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "switch") + + if unload_ok: + if hass.data[DOMAIN][entry.entry_id]: + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok + + class SW16Device(Entity): """Representation of a HLK-SW16 device. Contains the common logic for HLK-SW16 entities. """ - def __init__(self, relay_name, device_port, device_id, client): + def __init__(self, device_port, entry_id, client): """Initialize the device.""" # HLK-SW16 specific attributes for every component type - self._device_id = device_id + self._entry_id = entry_id self._device_port = device_port self._is_on = None self._client = client - self._name = relay_name + self._name = device_port + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._entry_id}_{self._device_port}" @callback def handle_event_callback(self, event): """Propagate changes through ha.""" - _LOGGER.debug("Relay %s new state callback: %r", self._device_port, event) + _LOGGER.debug("Relay %s new state callback: %r", self.unique_id, event) self._is_on = event self.async_write_ha_state() @@ -167,7 +192,7 @@ class SW16Device(Entity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"hlk_sw16_device_available_{self._device_id}", + f"hlk_sw16_device_available_{self._entry_id}", self._availability_callback, ) ) diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py new file mode 100644 index 00000000000..0a9ac79d1b7 --- /dev/null +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -0,0 +1,96 @@ +"""Config flow for HLK-SW16.""" +import asyncio + +from hlk_sw16 import create_hlk_sw16_connection +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from .const import ( + CONNECTION_TIMEOUT, + DEFAULT_KEEP_ALIVE_INTERVAL, + DEFAULT_PORT, + DEFAULT_RECONNECT_INTERVAL, + DOMAIN, +) +from .errors import AlreadyConfigured, CannotConnect + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + } +) + + +async def connect_client(hass, user_input): + """Connect the HLK-SW16 client.""" + client_aw = create_hlk_sw16_connection( + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL, + keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + ) + return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT) + + +async def validate_input(hass: HomeAssistant, user_input): + """Validate the user input allows us to connect.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if ( + entry.data[CONF_HOST] == user_input[CONF_HOST] + and entry.data[CONF_PORT] == user_input[CONF_PORT] + ): + raise AlreadyConfigured + + try: + client = await connect_client(hass, user_input) + except asyncio.TimeoutError: + raise CannotConnect + try: + + def disconnect_callback(): + if client.in_transaction: + client.active_transaction.set_exception(CannotConnect) + + client.disconnect_callback = disconnect_callback + await client.status() + except CannotConnect: + client.disconnect_callback = None + client.stop() + raise CannotConnect + else: + client.disconnect_callback = None + client.stop() + + +class SW16FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a HLK-SW16 config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_import(self, user_input): + """Handle import.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + await validate_input(self.hass, user_input) + address = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + return self.async_create_entry(title=address, data=user_input) + except AlreadyConfigured: + errors["base"] = "already_configured" + except CannotConnect: + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/hlk_sw16/const.py b/homeassistant/components/hlk_sw16/const.py new file mode 100644 index 00000000000..22bc29e7599 --- /dev/null +++ b/homeassistant/components/hlk_sw16/const.py @@ -0,0 +1,9 @@ +"""Constants for HLK-SW16 component.""" + +DOMAIN = "hlk_sw16" + +DEFAULT_NAME = "HLK-SW16" +DEFAULT_PORT = 8080 +DEFAULT_RECONNECT_INTERVAL = 10 +DEFAULT_KEEP_ALIVE_INTERVAL = 3 +CONNECTION_TIMEOUT = 10 diff --git a/homeassistant/components/hlk_sw16/errors.py b/homeassistant/components/hlk_sw16/errors.py new file mode 100644 index 00000000000..5b29587deba --- /dev/null +++ b/homeassistant/components/hlk_sw16/errors.py @@ -0,0 +1,14 @@ +"""Errors for the HLK-SW16 component.""" +from homeassistant.exceptions import HomeAssistantError + + +class SW16Exception(HomeAssistantError): + """Base class for HLK-SW16 exceptions.""" + + +class AlreadyConfigured(SW16Exception): + """HLK-SW16 is already configured.""" + + +class CannotConnect(SW16Exception): + """Unable to connect to the HLK-SW16.""" diff --git a/homeassistant/components/hlk_sw16/manifest.json b/homeassistant/components/hlk_sw16/manifest.json index 7574076fd43..aee829f593a 100644 --- a/homeassistant/components/hlk_sw16/manifest.json +++ b/homeassistant/components/hlk_sw16/manifest.json @@ -2,6 +2,11 @@ "domain": "hlk_sw16", "name": "Hi-Link HLK-SW16", "documentation": "https://www.home-assistant.io/integrations/hlk_sw16", - "requirements": ["hlk-sw16==0.0.8"], - "codeowners": [] -} + "requirements": [ + "hlk-sw16==0.0.8" + ], + "codeowners": [ + "@jameshilliard" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json new file mode 100644 index 00000000000..2480ac60918 --- /dev/null +++ b/homeassistant/components/hlk_sw16/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py index e9c190678a6..9bd10ea765d 100644 --- a/homeassistant/components/hlk_sw16/switch.py +++ b/homeassistant/components/hlk_sw16/switch.py @@ -1,30 +1,30 @@ """Support for HLK-SW16 switches.""" -import logging - from homeassistant.components.switch import ToggleEntity -from homeassistant.const import CONF_NAME from . import DATA_DEVICE_REGISTER, SW16Device +from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 -def devices_from_config(hass, domain_config): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the HLK-SW16 switches.""" + + +def devices_from_entities(hass, entry): """Parse configuration and add HLK-SW16 switch devices.""" - switches = domain_config[0] - device_id = domain_config[1] - device_client = hass.data[DATA_DEVICE_REGISTER][device_id] + device_client = hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] devices = [] - for device_port, device_config in switches.items(): - device_name = device_config.get(CONF_NAME, device_port) - device = SW16Switch(device_name, device_port, device_id, device_client) + for i in range(16): + device_port = f"{i:01x}" + device = SW16Switch(device_port, entry.entry_id, device_client) devices.append(device) return devices -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, entry, async_add_entities): """Set up the HLK-SW16 platform.""" - async_add_entities(devices_from_config(hass, discovery_info)) + async_add_entities(devices_from_entities(hass, entry)) class SW16Switch(SW16Device, ToggleEntity): diff --git a/homeassistant/components/hlk_sw16/translations/cs.json b/homeassistant/components/hlk_sw16/translations/cs.json new file mode 100644 index 00000000000..480a7187a7a --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed ji\u017e je nakonfigurov\u00e1no" + }, + "error": { + "cannot_connect": "Nelze se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/en.json b/homeassistant/components/hlk_sw16/translations/en.json new file mode 100644 index 00000000000..f15fe84c3ed --- /dev/null +++ b/homeassistant/components/hlk_sw16/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": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/es.json b/homeassistant/components/hlk_sw16/translations/es.json new file mode 100644 index 00000000000..2609ee07eaf --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/no.json b/homeassistant/components/hlk_sw16/translations/no.json new file mode 100644 index 00000000000..1cb08943e34 --- /dev/null +++ b/homeassistant/components/hlk_sw16/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": { + "host": "Vert", + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/ru.json b/homeassistant/components/hlk_sw16/translations/ru.json new file mode 100644 index 00000000000..ee2788cea56 --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\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", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 494daa54b23..2b6db6af528 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -11,6 +11,7 @@ from homeassistant.components import zeroconf from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, DOMAIN as BINARY_SENSOR_DOMAIN, ) from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN @@ -55,6 +56,7 @@ from .const import ( CONF_FILTER, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_SAFE_MODE, @@ -487,6 +489,7 @@ class HomeKit: { (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING), (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION), + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY), (SENSOR_DOMAIN, DEVICE_CLASS_BATTERY), (SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY), } @@ -631,6 +634,13 @@ class HomeKit: self._config.setdefault(state.entity_id, {}).setdefault( CONF_LINKED_MOTION_SENSOR, motion_binary_sensor_entity_id, ) + doorbell_binary_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY) + ) + if doorbell_binary_sensor_entity_id: + self._config.setdefault(state.entity_id, {}).setdefault( + CONF_LINKED_DOORBELL_SENSOR, doorbell_binary_sensor_entity_id, + ) if state.entity_id.startswith(f"{HUMIDIFIER_DOMAIN}."): current_humidity_sensor_entity_id = device_lookup[ diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index ead5179b5dc..d8eec057191 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -41,6 +41,7 @@ CONF_FEATURE_LIST = "feature_list" CONF_FILTER = "filter" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" +CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor" CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" @@ -55,6 +56,7 @@ CONF_SUPPORT_AUDIO = "support_audio" CONF_VIDEO_CODEC = "video_codec" CONF_VIDEO_MAP = "video_map" CONF_VIDEO_PACKET_SIZE = "video_packet_size" +CONF_STREAM_COUNT = "stream_count" # #### Config Defaults #### DEFAULT_SUPPORT_AUDIO = False @@ -72,6 +74,7 @@ DEFAULT_SAFE_MODE = False DEFAULT_VIDEO_CODEC = VIDEO_CODEC_LIBX264 DEFAULT_VIDEO_MAP = "0:v:0" DEFAULT_VIDEO_PACKET_SIZE = 1316 +DEFAULT_STREAM_COUNT = 3 # #### Features #### FEATURE_ON_OFF = "on_off" @@ -110,6 +113,7 @@ SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" +SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" @@ -124,6 +128,8 @@ SERV_OCCUPANCY_SENSOR = "OccupancySensor" SERV_OUTLET = "Outlet" SERV_SECURITY_SYSTEM = "SecuritySystem" SERV_SMOKE_SENSOR = "SmokeSensor" +SERV_SPEAKER = "Speaker" +SERV_STATELESS_PROGRAMMABLE_SWITCH = "StatelessProgrammableSwitch" SERV_SWITCH = "Switch" SERV_TELEVISION = "Television" SERV_TELEVISION_SPEAKER = "TelevisionSpeaker" @@ -182,6 +188,7 @@ CHAR_OCCUPANCY_DETECTED = "OccupancyDetected" CHAR_ON = "On" CHAR_OUTLET_IN_USE = "OutletInUse" CHAR_POSITION_STATE = "PositionState" +CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent" CHAR_REMOTE_KEY = "RemoteKey" CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 916a8cbde76..87ef0dc5ec8 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==2.9.2", + "HAP-python==3.0.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1", diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 836defe3b35..38599b5476f 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -22,6 +22,7 @@ "step": { "advanced": { "data": { + "auto_start": "Autostart (deaktiver hvis du bruker Z-Wave eller annet forsinket startsystem)", "safe_mode": "Sikker modus (aktiver bare hvis sammenkoblingen mislykkes)", "zeroconf_default_interface": "Bruk standard zeroconf-grensesnitt (aktiver hvis broen ikke kan finnes i Hjem-appen)" }, diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 9cfacc9866d..91b13a93eca 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -5,7 +5,6 @@ import logging from haffmpeg.core import HAFFmpeg from pyhap.camera import ( - STREAMING_STATUS, VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, Camera as PyhapCamera, @@ -24,15 +23,18 @@ from homeassistant.util import get_local_ip from .accessories import TYPES, HomeAccessory from .const import ( CHAR_MOTION_DETECTED, - CHAR_STREAMING_STRATUS, + CHAR_MUTE, + CHAR_PROGRAMMABLE_SWITCH_EVENT, CONF_AUDIO_CODEC, CONF_AUDIO_MAP, CONF_AUDIO_PACKET_SIZE, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_MAX_FPS, CONF_MAX_HEIGHT, CONF_MAX_WIDTH, CONF_STREAM_ADDRESS, + CONF_STREAM_COUNT, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, @@ -44,18 +46,24 @@ from .const import ( DEFAULT_MAX_FPS, DEFAULT_MAX_HEIGHT, DEFAULT_MAX_WIDTH, + DEFAULT_STREAM_COUNT, DEFAULT_SUPPORT_AUDIO, DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, - SERV_CAMERA_RTP_STREAM_MANAGEMENT, + SERV_DOORBELL, SERV_MOTION_SENSOR, + SERV_SPEAKER, + SERV_STATELESS_PROGRAMMABLE_SWITCH, ) from .img_util import scale_jpeg_camera_image from .util import pid_is_alive _LOGGER = logging.getLogger(__name__) +DOORBELL_SINGLE_PRESS = 0 +DOORBELL_DOUBLE_PRESS = 1 +DOORBELL_LONG_PRESS = 2 VIDEO_OUTPUT = ( "-map {v_map} -an " @@ -121,6 +129,7 @@ CONFIG_DEFAULTS = { CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC, CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE, CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE, + CONF_STREAM_COUNT: DEFAULT_STREAM_COUNT, } @@ -131,7 +140,6 @@ class Camera(HomeAccessory, PyhapCamera): def __init__(self, hass, driver, name, entity_id, aid, config): """Initialize a Camera accessory object.""" self._ffmpeg = hass.data[DATA_FFMPEG] - self._cur_session = None for config_key in CONFIG_DEFAULTS: if config_key not in config: config[config_key] = CONFIG_DEFAULTS[config_key] @@ -178,6 +186,7 @@ class Camera(HomeAccessory, PyhapCamera): "audio": audio_options, "address": stream_address, "srtp": True, + "stream_count": config[CONF_STREAM_COUNT], } super().__init__( @@ -190,18 +199,41 @@ class Camera(HomeAccessory, PyhapCamera): category=CATEGORY_CAMERA, options=options, ) + self._char_motion_detected = None self.linked_motion_sensor = self.config.get(CONF_LINKED_MOTION_SENSOR) - if not self.linked_motion_sensor: - return - state = self.hass.states.get(self.linked_motion_sensor) - if not state: - return - serv_motion = self.add_preload_service(SERV_MOTION_SENSOR) - self._char_motion_detected = serv_motion.configure_char( - CHAR_MOTION_DETECTED, value=False - ) - self._async_update_motion_state(state) + if self.linked_motion_sensor: + state = self.hass.states.get(self.linked_motion_sensor) + if state: + serv_motion = self.add_preload_service(SERV_MOTION_SENSOR) + self._char_motion_detected = serv_motion.configure_char( + CHAR_MOTION_DETECTED, value=False + ) + self._async_update_motion_state(state) + + self._char_doorbell_detected = None + self._char_doorbell_detected_switch = None + self.linked_doorbell_sensor = self.config.get(CONF_LINKED_DOORBELL_SENSOR) + if self.linked_doorbell_sensor: + state = self.hass.states.get(self.linked_doorbell_sensor) + if state: + serv_doorbell = self.add_preload_service(SERV_DOORBELL) + self.set_primary_service(serv_doorbell) + self._char_doorbell_detected = serv_doorbell.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, value=0, + ) + serv_stateless_switch = self.add_preload_service( + SERV_STATELESS_PROGRAMMABLE_SWITCH + ) + self._char_doorbell_detected_switch = serv_stateless_switch.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, + value=0, + valid_values={"SinglePress": DOORBELL_SINGLE_PRESS}, + ) + serv_speaker = self.add_preload_service(SERV_SPEAKER) + serv_speaker.configure_char(CHAR_MUTE, value=0) + + self._async_update_doorbell_state(state) async def run_handler(self): """Handle accessory driver started event. @@ -215,6 +247,13 @@ class Camera(HomeAccessory, PyhapCamera): self._async_update_motion_state_event, ) + if self._char_doorbell_detected: + async_track_state_change_event( + self.hass, + [self.linked_doorbell_sensor], + self._async_update_doorbell_state_event, + ) + await super().run_handler() @callback @@ -240,6 +279,27 @@ class Camera(HomeAccessory, PyhapCamera): detected, ) + @callback + def _async_update_doorbell_state_event(self, event): + """Handle state change event listener callback.""" + self._async_update_doorbell_state(event.data.get("new_state")) + + @callback + def _async_update_doorbell_state(self, new_state): + """Handle link doorbell sensor state change to update HomeKit value.""" + if not new_state: + return + + if new_state.state == STATE_ON: + self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS) + self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS) + _LOGGER.debug( + "%s: Set linked doorbell %s sensor to %d", + self.entity_id, + self.linked_doorbell_sensor, + DOORBELL_SINGLE_PRESS, + ) + @callback def async_update_state(self, new_state): """Handle state change to update HomeKit value.""" @@ -313,51 +373,42 @@ class Camera(HomeAccessory, PyhapCamera): if not opened: _LOGGER.error("Failed to open ffmpeg stream") return False - session_info["stream"] = stream + _LOGGER.info( "[%s] Started stream process - PID %d", session_info["id"], stream.process.pid, ) - ffmpeg_watcher = async_track_time_interval( - self.hass, self._async_ffmpeg_watch, FFMPEG_WATCH_INTERVAL + session_info["stream"] = stream + session_info[FFMPEG_PID] = stream.process.pid + + async def watch_session(_): + await self._async_ffmpeg_watch(session_info["id"]) + + session_info[FFMPEG_WATCHER] = async_track_time_interval( + self.hass, watch_session, FFMPEG_WATCH_INTERVAL, ) - self._cur_session = { - FFMPEG_WATCHER: ffmpeg_watcher, - FFMPEG_PID: stream.process.pid, - SESSION_ID: session_info["id"], - } - return await self._async_ffmpeg_watch(0) + return await self._async_ffmpeg_watch(session_info["id"]) - async def _async_ffmpeg_watch(self, _): + async def _async_ffmpeg_watch(self, session_id): """Check to make sure ffmpeg is still running and cleanup if not.""" - ffmpeg_pid = self._cur_session[FFMPEG_PID] - session_id = self._cur_session[SESSION_ID] + ffmpeg_pid = self.sessions[session_id][FFMPEG_PID] if pid_is_alive(ffmpeg_pid): return True _LOGGER.warning("Streaming process ended unexpectedly - PID %d", ffmpeg_pid) - self._async_stop_ffmpeg_watch() - self._async_set_streaming_available(session_id) + self._async_stop_ffmpeg_watch(session_id) + self.set_streaming_available(self.sessions[session_id]["stream_idx"]) return False @callback - def _async_stop_ffmpeg_watch(self): + def _async_stop_ffmpeg_watch(self, session_id): """Cleanup a streaming session after stopping.""" - if not self._cur_session: + if FFMPEG_WATCHER not in self.sessions[session_id]: return - self._cur_session[FFMPEG_WATCHER]() - self._cur_session = None - - @callback - def _async_set_streaming_available(self, session_id): - """Free the session so they can start another.""" - self.streaming_status = STREAMING_STATUS["AVAILABLE"] - self.get_service(SERV_CAMERA_RTP_STREAM_MANAGEMENT).get_characteristic( - CHAR_STREAMING_STRATUS - ).notify() + self.sessions[session_id].pop(FFMPEG_WATCHER)() async def stop_stream(self, session_info): """Stop the stream for the given ``session_id``.""" @@ -367,7 +418,7 @@ class Camera(HomeAccessory, PyhapCamera): _LOGGER.debug("No stream for session ID %s", session_id) return - self._async_stop_ffmpeg_watch() + self._async_stop_ffmpeg_watch(session_id) if not pid_is_alive(stream.process.pid): _LOGGER.info("[%s] Stream already stopped", session_id) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index dca75ee83fb..91cdd25ee42 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -260,13 +260,11 @@ class TelevisionMediaPlayer(HomeAccessory): self.sources = [] - # Add additional characteristics if volume or input selection supported - self.chars_tv = [] + self.chars_tv = [CHAR_REMOTE_KEY] self.chars_speaker = [] features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & (SUPPORT_PLAY | SUPPORT_PAUSE): - self.chars_tv.append(CHAR_REMOTE_KEY) + self._supports_play_pause = features & (SUPPORT_PLAY | SUPPORT_PAUSE) if features & SUPPORT_VOLUME_MUTE or features & SUPPORT_VOLUME_STEP: self.chars_speaker.extend( (CHAR_NAME, CHAR_ACTIVE, CHAR_VOLUME_CONTROL_TYPE, CHAR_VOLUME_SELECTOR) @@ -285,10 +283,9 @@ class TelevisionMediaPlayer(HomeAccessory): CHAR_ACTIVE, setter_callback=self.set_on_off ) - if CHAR_REMOTE_KEY in self.chars_tv: - self.char_remote_key = serv_tv.configure_char( - CHAR_REMOTE_KEY, setter_callback=self.set_remote_key - ) + self.char_remote_key = serv_tv.configure_char( + CHAR_REMOTE_KEY, setter_callback=self.set_remote_key + ) if CHAR_VOLUME_SELECTOR in self.chars_speaker: serv_speaker = self.add_preload_service( @@ -382,7 +379,7 @@ class TelevisionMediaPlayer(HomeAccessory): _LOGGER.warning("%s: Unhandled key press for %s", self.entity_id, value) return - if key_name == KEY_PLAY_PAUSE: + if key_name == KEY_PLAY_PAUSE and self._supports_play_pause: # Handle Play Pause by directly updating the media player entity. state = self.hass.states.get(self.entity_id).state if state in (STATE_PLAYING, STATE_PAUSED): @@ -394,7 +391,7 @@ class TelevisionMediaPlayer(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} self.call_service(DOMAIN, service, params) else: - # Other keys can be handled by listening to the event bus + # Unhandled keys can be handled by listening to the event bus self.hass.bus.fire( EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, {ATTR_KEY_NAME: key_name, ATTR_ENTITY_ID: self.entity_id}, diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index c79b97adb87..2199371c00d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -34,6 +34,7 @@ from .const import ( CONF_FEATURE_LIST, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_LOW_BATTERY_THRESHOLD, @@ -41,6 +42,7 @@ from .const import ( CONF_MAX_HEIGHT, CONF_MAX_WIDTH, CONF_STREAM_ADDRESS, + CONF_STREAM_COUNT, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, @@ -53,6 +55,7 @@ from .const import ( DEFAULT_MAX_FPS, DEFAULT_MAX_HEIGHT, DEFAULT_MAX_WIDTH, + DEFAULT_STREAM_COUNT, DEFAULT_SUPPORT_AUDIO, DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, @@ -112,6 +115,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( vol.Optional(CONF_MAX_FPS, default=DEFAULT_MAX_FPS): cv.positive_int, vol.Optional(CONF_AUDIO_MAP, default=DEFAULT_AUDIO_MAP): cv.string, vol.Optional(CONF_VIDEO_MAP, default=DEFAULT_VIDEO_MAP): cv.string, + vol.Optional(CONF_STREAM_COUNT, default=DEFAULT_STREAM_COUNT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=10) + ), vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In( VALID_VIDEO_CODECS ), @@ -122,6 +128,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( CONF_VIDEO_PACKET_SIZE, default=DEFAULT_VIDEO_PACKET_SIZE ): cv.positive_int, vol.Optional(CONF_LINKED_MOTION_SENSOR): cv.entity_domain(binary_sensor.DOMAIN), + vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain( + binary_sensor.DOMAIN + ), } ) @@ -345,7 +354,7 @@ def show_setup_message(hass, entry_id, bridge_name, pincode, uri): buffer = io.BytesIO() url = pyqrcode.create(uri) - url.svg(buffer, scale=5) + url.svg(buffer, scale=5, module_color="#000", background="#FFF") pairing_secret = secrets.token_hex(32) hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR] = buffer.getvalue() diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 47f3cf20571..4a8730b2e9e 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -11,6 +11,7 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import Service, ServicesTypes +from homeassistant.components import zeroconf from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity @@ -212,7 +213,8 @@ async def async_setup(hass, config): map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) await map_storage.async_initialize() - hass.data[CONTROLLER] = aiohomekit.Controller() + zeroconf_instance = await zeroconf.async_get_instance(hass) + hass.data[CONTROLLER] = aiohomekit.Controller(zeroconf_instance=zeroconf_instance) hass.data[KNOWN_DEVICES] = {} return True diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9bbaf959012..4d37a38e417 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[IP]==0.2.45"], + "requirements": ["aiohomekit[IP]==0.2.46"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homekit_controller/translations/pl.json b/homeassistant/components/homekit_controller/translations/pl.json index 498d927ffb8..94e3422338b 100644 --- a/homeassistant/components/homekit_controller/translations/pl.json +++ b/homeassistant/components/homekit_controller/translations/pl.json @@ -7,7 +7,7 @@ "already_paired": "To akcesorium jest ju\u017c sparowane z innym urz\u0105dzeniem. Zresetuj akcesorium i spr\u00f3buj ponownie.", "ignored_model": "Obs\u0142uga HomeKit dla tego modelu jest zablokowana, poniewa\u017c dost\u0119pna jest pe\u0142niejsza integracja natywna.", "invalid_config_entry": "To urz\u0105dzenie jest wy\u015bwietlane jako gotowe do sparowania, ale istnieje ju\u017c konfliktowy wpis konfiguracyjny dla niego w Home Assistant, kt\u00f3ry musi zosta\u0107 najpierw usuni\u0119ty.", - "no_devices": "Nie znaleziono niesparowanych urz\u0105dze\u0144" + "no_devices": "Nie znaleziono niesparowanych urz\u0105dze\u0144." }, "error": { "authentication_error": "Niepoprawny kod parowania HomeKit. Sprawd\u017a go i spr\u00f3buj ponownie.", diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index bbb8c1ff1ca..1ce18a8e759 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -66,6 +66,7 @@ HM_DEVICE_TYPES = { "ColorEffectLight", "IPKeySwitchLevel", "ColdWarmDimmer", + "IPWDimmer", ], DISCOVER_SENSORS: [ "SwitchPowermeter", @@ -110,6 +111,7 @@ HM_DEVICE_TYPES = { "IPThermostatWall2", "IPRemoteMotionV2", "HBUNISenWEA", + "IPWMotionDection", ], DISCOVER_CLIMATE: [ "Thermostat", @@ -151,8 +153,17 @@ HM_DEVICE_TYPES = { "IPContact", "IPRemoteMotionV2", "IPWInputDevice", + "IPWMotionDection", + ], + DISCOVER_COVER: [ + "Blind", + "KeyBlind", + "IPKeyBlind", + "IPKeyBlindTilt", + "IPGarage", + "IPKeyBlindMulti", + "IPWKeyBlindMulti", ], - DISCOVER_COVER: ["Blind", "KeyBlind", "IPKeyBlind", "IPKeyBlindTilt", "IPGarage"], DISCOVER_LOCKS: ["KeyMatic"], } diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index de55b941b91..d8c60c0f976 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -2,6 +2,6 @@ "domain": "homematic", "name": "Homematic", "documentation": "https://www.home-assistant.io/integrations/homematic", - "requirements": ["pyhomematic==0.1.67"], + "requirements": ["pyhomematic==0.1.68"], "codeowners": ["@pvizeli", "@danielperna84"] } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index b387cea350e..75f13caa6aa 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -30,6 +30,7 @@ from .cors import setup_cors from .real_ip import setup_real_ip from .static import CACHE_HEADERS, CachingStaticResource from .view import HomeAssistantView # noqa: F401 +from .web_runner import HomeAssistantTCPSite # mypy: allow-untyped-defs, no-check-untyped-defs @@ -53,7 +54,6 @@ SSL_INTERMEDIATE = "intermediate" _LOGGER = logging.getLogger(__name__) -DEFAULT_SERVER_HOST = "0.0.0.0" DEFAULT_DEVELOPMENT = "0" # To be able to load custom cards. DEFAULT_CORS = "https://cast.home-assistant.io" @@ -69,7 +69,9 @@ HTTP_SCHEMA = vol.All( cv.deprecated(CONF_BASE_URL), vol.Schema( { - vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, + vol.Optional(CONF_SERVER_HOST): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, @@ -190,7 +192,7 @@ async def async_setup(hass, config): if conf is None: conf = HTTP_SCHEMA({}) - server_host = conf[CONF_SERVER_HOST] + server_host = conf.get(CONF_SERVER_HOST) server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) @@ -255,8 +257,9 @@ async def async_setup(hass, config): if host: port = None - elif server_host != DEFAULT_SERVER_HOST: - host = server_host + elif server_host is not None: + # Assume the first server host name provided as API host + host = server_host[0] port = server_port else: host = local_ip @@ -412,7 +415,8 @@ class HomeAssistantHTTP: self.runner = web.AppRunner(self.app) await self.runner.setup() - self.site = web.TCPSite( + + self.site = HomeAssistantTCPSite( self.runner, self.server_host, self.server_port, ssl_context=context ) try: diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py new file mode 100644 index 00000000000..67621d63412 --- /dev/null +++ b/homeassistant/components/http/web_runner.py @@ -0,0 +1,67 @@ +"""HomeAssistant specific aiohttp Site.""" +import asyncio +from ssl import SSLContext +from typing import List, Optional, Union + +from aiohttp import web +from yarl import URL + + +class HomeAssistantTCPSite(web.BaseSite): + """HomeAssistant specific aiohttp Site. + + Vanilla TCPSite accepts only str as host. However, the underlying asyncio's + create_server() implementation does take a list of strings to bind to multiple + host IP's. To support multiple server_host entries (e.g. to enable dual-stack + explicitly), we would like to pass an array of strings. Bring our own + implementation inspired by TCPSite. + + Custom TCPSite can be dropped when https://github.com/aio-libs/aiohttp/pull/4894 + is merged. + """ + + __slots__ = ("_host", "_port", "_reuse_address", "_reuse_port", "_hosturl") + + def __init__( + self, + runner: "web.BaseRunner", + host: Union[None, str, List[str]], + port: int, + *, + shutdown_timeout: float = 60.0, + ssl_context: Optional[SSLContext] = None, + backlog: int = 128, + reuse_address: Optional[bool] = None, + reuse_port: Optional[bool] = None, + ) -> None: # noqa: D107 + super().__init__( + runner, + shutdown_timeout=shutdown_timeout, + ssl_context=ssl_context, + backlog=backlog, + ) + self._host = host + self._port = port + self._reuse_address = reuse_address + self._reuse_port = reuse_port + + @property + def name(self) -> str: # noqa: D102 + scheme = "https" if self._ssl_context else "http" + host = self._host[0] if isinstance(self._host, list) else "0.0.0.0" + return str(URL.build(scheme=scheme, host=host, port=self._port)) + + async def start(self) -> None: # noqa: D102 + await super().start() + loop = asyncio.get_running_loop() + server = self._runner.server + assert server is not None + self._server = await loop.create_server( + server, + self._host, + self._port, + ssl=self._ssl_context, + backlog=self._backlog, + reuse_address=self._reuse_address, + reuse_port=self._reuse_port, + ) diff --git a/homeassistant/components/huawei_lte/translations/es.json b/homeassistant/components/huawei_lte/translations/es.json index b9d4ae2afc8..268fdf8eff5 100644 --- a/homeassistant/components/huawei_lte/translations/es.json +++ b/homeassistant/components/huawei_lte/translations/es.json @@ -33,7 +33,7 @@ "step": { "init": { "data": { - "name": "Nombre del servicio de notificaci\u00f3n", + "name": "Nombre del servicio de notificaci\u00f3n (el cambio requiere reiniciar)", "recipient": "Destinatarios de notificaciones por SMS", "track_new_devices": "Rastrea nuevos dispositivos" } diff --git a/homeassistant/components/huawei_lte/translations/tr.json b/homeassistant/components/huawei_lte/translations/tr.json new file mode 100644 index 00000000000..a76e31fa483 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/tr.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "track_new_devices": "Yeni cihazlar\u0131 izle" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/it.json b/homeassistant/components/hue/translations/it.json index f281548e017..b2320be646b 100644 --- a/homeassistant/components/hue/translations/it.json +++ b/homeassistant/components/hue/translations/it.json @@ -59,6 +59,7 @@ "init": { "data": { "allow_how_groups": "Consenti gruppi Hue", + "allow_hue_groups": "Consenti gruppi Hue", "allow_unreachable": "Consentire alle lampadine irraggiungibili di segnalare correttamente il loro stato" } } diff --git a/homeassistant/components/hue/translations/lb.json b/homeassistant/components/hue/translations/lb.json index 4e33d39072c..b6af356f387 100644 --- a/homeassistant/components/hue/translations/lb.json +++ b/homeassistant/components/hue/translations/lb.json @@ -58,7 +58,8 @@ "step": { "init": { "data": { - "allow_how_groups": "Hue Gruppen erlaaben" + "allow_how_groups": "Hue Gruppen erlaaben", + "allow_hue_groups": "Hue Gruppen erlaaben" } } } diff --git a/homeassistant/components/hue/translations/no.json b/homeassistant/components/hue/translations/no.json index 6e2b78a2ead..c5e9cedd708 100644 --- a/homeassistant/components/hue/translations/no.json +++ b/homeassistant/components/hue/translations/no.json @@ -24,6 +24,12 @@ "link": { "description": "Trykk p\u00e5 knappen p\u00e5 Bridgen for \u00e5 registrere Philips Hue med Home Assistant. \n\n ![Knappens plassering p\u00e5 Bridgen](/static/images/config_philips_hue.jpg)", "title": "" + }, + "manual": { + "data": { + "host": "Vert" + }, + "title": "Manuell konfigurere en Hue-bro" } } }, @@ -53,7 +59,8 @@ "init": { "data": { "allow_how_groups": "Tillat Hue-grupper", - "allow_hue_groups": "Tillat Hue-grupper" + "allow_hue_groups": "Tillat Hue-grupper", + "allow_unreachable": "Tillat uoppn\u00e5elige p\u00e6rer \u00e5 rapportere sin tilstand riktig" } } } diff --git a/homeassistant/components/hue/translations/pl.json b/homeassistant/components/hue/translations/pl.json index 80f5e31bc0e..01147d35663 100644 --- a/homeassistant/components/hue/translations/pl.json +++ b/homeassistant/components/hue/translations/pl.json @@ -26,6 +26,9 @@ "title": "Hub Link" }, "manual": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, "title": "R\u0119czna konfiguracja mostu Hue" } } diff --git a/homeassistant/components/hue/translations/sl.json b/homeassistant/components/hue/translations/sl.json index a8d88ec49ac..c68971f36f9 100644 --- a/homeassistant/components/hue/translations/sl.json +++ b/homeassistant/components/hue/translations/sl.json @@ -47,5 +47,14 @@ "remote_double_button_long_press": "Po dolgem pritisku sta obe \" {subtype} \" spro\u0161\u010deni", "remote_double_button_short_press": "Spro\u0161\u010dena oba \"{podvrsta}\"" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_hue_groups": "Dovoli skupine Hue" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py new file mode 100644 index 00000000000..7f37fc3b1fa --- /dev/null +++ b/homeassistant/components/humidifier/device_condition.py @@ -0,0 +1,103 @@ +"""Provide the device automations for Humidifier.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.components.device_automation import toggle_entity +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN, const + +TOGGLE_CONDITION = toggle_entity.CONDITION_SCHEMA.extend( + {vol.Required(CONF_DOMAIN): DOMAIN} +) + +MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "is_mode", + vol.Required(const.ATTR_MODE): str, + } +) + +CONDITION_SCHEMA = vol.Any(TOGGLE_CONDITION, MODE_CONDITION) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions for Humidifier devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions = await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + if state and state.attributes["supported_features"] & const.SUPPORT_MODES: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_mode", + } + ) + + return conditions + + +@callback +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + + if config[CONF_TYPE] == "is_mode": + attribute = const.ATTR_MODE + else: + return toggle_entity.async_condition_from_config(config) + + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + state = hass.states.get(config[ATTR_ENTITY_ID]) + return state and state.attributes.get(attribute) == config[attribute] + + return test_is_state + + +async def async_get_condition_capabilities(hass, config): + """List condition capabilities.""" + state = hass.states.get(config[CONF_ENTITY_ID]) + condition_type = config[CONF_TYPE] + + fields = {} + + if condition_type == "is_mode": + if state: + modes = state.attributes.get(const.ATTR_AVAILABLE_MODES, []) + else: + modes = [] + + fields[vol.Required(const.ATTR_AVAILABLE_MODES)] = vol.In(modes) + + return {"extra_fields": vol.Schema(fields)} + + return await toggle_entity.async_get_condition_capabilities(hass, config) diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py new file mode 100644 index 00000000000..906fb96bede --- /dev/null +++ b/homeassistant/components/humidifier/device_trigger.py @@ -0,0 +1,126 @@ +"""Provides device automations for Climate.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + numeric_state as numeric_state_automation, +) +from homeassistant.components.device_automation import ( + TRIGGER_BASE_SCHEMA, + toggle_entity, +) +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + CONF_TYPE, + UNIT_PERCENTAGE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN + +TARGET_TRIGGER_SCHEMA = vol.All( + TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "target_humidity_changed", + vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(int)), + vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(int)), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + +TOGGLE_TRIGGER_SCHEMA = toggle_entity.TRIGGER_SCHEMA.extend( + {vol.Required(CONF_DOMAIN): DOMAIN} +) + +TRIGGER_SCHEMA = vol.Any(TARGET_TRIGGER_SCHEMA, TOGGLE_TRIGGER_SCHEMA) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """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) + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "target_humidity_changed", + } + ) + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + trigger_type = config[CONF_TYPE] + + if trigger_type == "target_humidity_changed": + numeric_state_config = { + numeric_state_automation.CONF_PLATFORM: "numeric_state", + numeric_state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + numeric_state_automation.CONF_VALUE_TEMPLATE: "{{ state.attributes.humidity }}", + } + + if CONF_ABOVE in config: + numeric_state_config[CONF_ABOVE] = config[CONF_ABOVE] + if CONF_BELOW in config: + numeric_state_config[CONF_BELOW] = config[CONF_BELOW] + if CONF_FOR in config: + numeric_state_config[CONF_FOR] = config[CONF_FOR] + + numeric_state_config = numeric_state_automation.TRIGGER_SCHEMA( + numeric_state_config + ) + return await numeric_state_automation.async_attach_trigger( + hass, numeric_state_config, action, automation_info, platform_type="device" + ) + + return await toggle_entity.async_attach_trigger( + hass, config, action, automation_info + ) + + +async def async_get_trigger_capabilities(hass: HomeAssistant, config): + """List trigger capabilities.""" + trigger_type = config[CONF_TYPE] + + if trigger_type == "target_humidity_changed": + return { + "extra_fields": vol.Schema( + { + vol.Optional( + CONF_ABOVE, description={"suffix": UNIT_PERCENTAGE} + ): vol.Coerce(int), + vol.Optional( + CONF_BELOW, description={"suffix": UNIT_PERCENTAGE} + ): vol.Coerce(int), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ) + } + return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py new file mode 100644 index 00000000000..ee257cc7123 --- /dev/null +++ b/homeassistant/components/humidifier/intent.py @@ -0,0 +1,127 @@ +"""Intents for the humidifier integration.""" +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv + +from . import ( + ATTR_AVAILABLE_MODES, + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_ON, + SUPPORT_MODES, +) + +INTENT_HUMIDITY = "HassHumidifierSetpoint" +INTENT_MODE = "HassHumidifierMode" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the humidifier intents.""" + hass.helpers.intent.async_register(HumidityHandler()) + hass.helpers.intent.async_register(SetModeHandler()) + + +class HumidityHandler(intent.IntentHandler): + """Handle set humidity intents.""" + + intent_type = INTENT_HUMIDITY + slot_schema = { + vol.Required("name"): cv.string, + vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)), + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + state = hass.helpers.intent.async_match_state( + slots["name"]["value"], + [state for state in hass.states.async_all() if state.domain == DOMAIN], + ) + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + humidity = slots["humidity"]["value"] + + if state.state == STATE_OFF: + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, service_data, context=intent_obj.context + ) + speech = f"Turned {state.name} on and set humidity to {humidity}%" + else: + speech = f"The {state.name} is set to {humidity}%" + + service_data[ATTR_HUMIDITY] = humidity + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + service_data, + context=intent_obj.context, + blocking=True, + ) + + response = intent_obj.create_response() + + response.async_set_speech(speech) + return response + + +class SetModeHandler(intent.IntentHandler): + """Handle set humidity intents.""" + + intent_type = INTENT_MODE + slot_schema = { + vol.Required("name"): cv.string, + vol.Required("mode"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + state = hass.helpers.intent.async_match_state( + slots["name"]["value"], + [state for state in hass.states.async_all() if state.domain == DOMAIN], + ) + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + intent.async_test_feature(state, SUPPORT_MODES, "modes") + mode = slots["mode"]["value"] + + if mode not in state.attributes.get(ATTR_AVAILABLE_MODES, []): + raise intent.IntentHandleError( + f"Entity {state.name} does not support {mode} mode" + ) + + if state.state == STATE_OFF: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + service_data, + context=intent_obj.context, + blocking=True, + ) + speech = f"Turned {state.name} on and set {mode} mode" + else: + speech = f"The mode for {state.name} is set to {mode}" + + service_data[ATTR_MODE] = mode + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + service_data, + context=intent_obj.context, + blocking=True, + ) + + response = intent_obj.create_response() + + response.async_set_speech(speech) + return response diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index de7086cd053..5a8864b496b 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,6 +1,16 @@ { "title": "Humidifier", "device_automation": { + "trigger_type": { + "target_humidity_changed": "{entity_name} target humidity changed", + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + }, + "condition_type": { + "is_mode": "{entity_name} is set to a specific mode", + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, "action_type": { "set_humidity": "Set humidity for {entity_name}", "set_mode": "Change mode on {entity_name}", diff --git a/homeassistant/components/humidifier/translations/ca.json b/homeassistant/components/humidifier/translations/ca.json index 353e590d59b..bf0c1d805f6 100644 --- a/homeassistant/components/humidifier/translations/ca.json +++ b/homeassistant/components/humidifier/translations/ca.json @@ -6,6 +6,16 @@ "toggle": "Commuta {entity_name}", "turn_off": "Apaga {entity_name}", "turn_on": "Enc\u00e9n {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} est\u00e0 configurat/ada en un mode espec\u00edfic", + "is_off": "{entity_name} est\u00e0 apagat/ada", + "is_on": "{entity_name} est\u00e0 enc\u00e8s/a" + }, + "trigger_type": { + "target_humidity_changed": "Ha canviat la humitat desitjada de {entity_name}", + "turned_off": "{entity_name} s'ha apagat", + "turned_on": "{entity_name} s'ha engegat" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/en.json b/homeassistant/components/humidifier/translations/en.json index 5a5f803b2a3..be3f013895d 100644 --- a/homeassistant/components/humidifier/translations/en.json +++ b/homeassistant/components/humidifier/translations/en.json @@ -6,6 +6,16 @@ "toggle": "Toggle {entity_name}", "turn_off": "Turn off {entity_name}", "turn_on": "Turn on {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} is set to a specific mode", + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} target humidity changed", + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/es.json b/homeassistant/components/humidifier/translations/es.json index 2c867e0bc73..357ca767534 100644 --- a/homeassistant/components/humidifier/translations/es.json +++ b/homeassistant/components/humidifier/translations/es.json @@ -6,6 +6,16 @@ "toggle": "Alternar {entity_name}", "turn_off": "Apagar {entity_name}", "turn_on": "Encender {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} est\u00e1 configurado en un modo espec\u00edfico", + "is_off": "{entity_name} est\u00e1 apagado", + "is_on": "{entity_name} est\u00e1 activado" + }, + "trigger_type": { + "target_humidity_changed": "La humedad objetivo ha cambiado en {entity_name}", + "turned_off": "{entity_name} desactivado", + "turned_on": "{entity_name} activado" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/it.json b/homeassistant/components/humidifier/translations/it.json index 19b9102fbf3..2d2caf1a9d7 100644 --- a/homeassistant/components/humidifier/translations/it.json +++ b/homeassistant/components/humidifier/translations/it.json @@ -6,6 +6,16 @@ "toggle": "Commuta {entity_name}", "turn_off": "Disattivare {entity_name}", "turn_on": "Attivare {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} \u00e8 impostato su una modalit\u00e0 specifica", + "is_off": "{entity_name} \u00e8 spento", + "is_on": "{entity_name} \u00e8 acceso" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} umidit\u00e0 target modificata", + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/ko.json b/homeassistant/components/humidifier/translations/ko.json index 89548dc4e35..c484a532156 100644 --- a/homeassistant/components/humidifier/translations/ko.json +++ b/homeassistant/components/humidifier/translations/ko.json @@ -6,6 +6,16 @@ "toggle": "{entity_name} \ud1a0\uae00", "turn_off": "{entity_name} \ub044\uae30", "turn_on": "{entity_name} \ucf1c\uae30" + }, + "condition_type": { + "is_mode": "{entity_name} \uc774(\uac00) \ud2b9\uc815 \ubaa8\ub4dc\ub85c \uc124\uc815\ub418\uc5b4\uc788\uc73c\uba74", + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} \ubaa9\ud45c \uc2b5\ub3c4\uac00 \ubcc0\uacbd\ub420 \ub54c", + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc9c8 \ub54c", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc9c8 \ub54c" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/lb.json b/homeassistant/components/humidifier/translations/lb.json index 3dc1261132f..ff20290e950 100644 --- a/homeassistant/components/humidifier/translations/lb.json +++ b/homeassistant/components/humidifier/translations/lb.json @@ -6,6 +6,16 @@ "toggle": "{entity_name} \u00ebmschalten", "turn_off": "{entity_name} ausschalten", "turn_on": "{entity_name} uschalten" + }, + "condition_type": { + "is_mode": "{entity_name} ass op e spezifesche Modus gesat", + "is_off": "{entity_name} ass ausgeschalt", + "is_on": "{entity_name} ass un" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} Ziel Fiichtegkeet ge\u00e4nnert", + "turned_off": "{entity_name} gouf ausgeschalt", + "turned_on": "{entity_name} gouf ugeschalt" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/nl.json b/homeassistant/components/humidifier/translations/nl.json new file mode 100644 index 00000000000..d74d37d20ad --- /dev/null +++ b/homeassistant/components/humidifier/translations/nl.json @@ -0,0 +1,9 @@ +{ + "device_automation": { + "trigger_type": { + "target_humidity_changed": "{entity_name} doel luchtvochtigheid gewijzigd", + "turned_off": "{entity_name} is uitgeschakeld", + "turned_on": "{entity_name} is ingeschakeld" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/no.json b/homeassistant/components/humidifier/translations/no.json index 42caaf0d774..620a38c6cbe 100644 --- a/homeassistant/components/humidifier/translations/no.json +++ b/homeassistant/components/humidifier/translations/no.json @@ -6,6 +6,22 @@ "toggle": "Veksle {entity_name}", "turn_off": "Sl\u00e5 av {entity_name}", "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} er satt til en spesifikk modus", + "is_off": "{entity_name} er av", + "is_on": "{entity_name} er p\u00e5" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} m\u00e5let fuktighet endret", + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5" + } + }, + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" } }, "title": "Luftfukter" diff --git a/homeassistant/components/humidifier/translations/pl.json b/homeassistant/components/humidifier/translations/pl.json index 0a57eede3b3..77b3241fab9 100644 --- a/homeassistant/components/humidifier/translations/pl.json +++ b/homeassistant/components/humidifier/translations/pl.json @@ -6,6 +6,16 @@ "toggle": "prze\u0142\u0105cz {entity_name}", "turn_off": "wy\u0142\u0105cz {entity_name}", "turn_on": "w\u0142\u0105cz {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} ma ustawiony tryb", + "is_off": "nawil\u017cacz {entity_name} jest wy\u0142\u0105czony", + "is_on": "nawil\u017cacz{entity_name} jest w\u0142\u0105czony" + }, + "trigger_type": { + "target_humidity_changed": "zmieni si\u0119 wilgotno\u015b\u0107 docelowa{entity_name}", + "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", + "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/ru.json b/homeassistant/components/humidifier/translations/ru.json index 32e19e8325b..8c2bcde5565 100644 --- a/homeassistant/components/humidifier/translations/ru.json +++ b/homeassistant/components/humidifier/translations/ru.json @@ -6,6 +6,16 @@ "toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u0437\u0430\u0434\u0430\u043d\u043d\u043e\u043c \u0440\u0435\u0436\u0438\u043c\u0435 \u0440\u0430\u0431\u043e\u0442\u044b", + "is_off": "{entity_name} \u0432 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0446\u0435\u043b\u0435\u0432\u043e\u0439 \u0432\u043b\u0430\u0436\u043d\u043e\u0441\u0442\u0438", + "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" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/sl.json b/homeassistant/components/humidifier/translations/sl.json new file mode 100644 index 00000000000..141d98c38dd --- /dev/null +++ b/homeassistant/components/humidifier/translations/sl.json @@ -0,0 +1,14 @@ +{ + "device_automation": { + "condition_type": { + "is_mode": "{entity_name} je nastavljen na dolo\u010den na\u010din", + "is_off": "{entity_name} je izklopljen", + "is_on": "{entity_name} je vklopljen" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} spremenjena ciljna vla\u017enost", + "turned_off": "{entity_name} izklopljen", + "turned_on": "{entity_name} vklopljen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/zh-Hant.json b/homeassistant/components/humidifier/translations/zh-Hant.json index c067d97d956..3e37ba38f64 100644 --- a/homeassistant/components/humidifier/translations/zh-Hant.json +++ b/homeassistant/components/humidifier/translations/zh-Hant.json @@ -6,6 +6,16 @@ "toggle": "\u5207\u63db{entity_name}", "turn_off": "\u95dc\u9589{entity_name}", "turn_on": "\u958b\u555f{entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name}\u8a2d\u5b9a\u70ba\u6307\u5b9a\u6a21\u5f0f", + "is_off": "{entity_name}\u5df2\u95dc\u9589", + "is_on": "{entity_name}\u5df2\u958b\u555f" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name}\u8a2d\u5b9a\u6fd5\u5ea6\u5df2\u8b8a\u66f4", + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f" } }, "state": { diff --git a/homeassistant/components/iaqualink/translations/es.json b/homeassistant/components/iaqualink/translations/es.json index 74ae609c0d2..95ebdf89c98 100644 --- a/homeassistant/components/iaqualink/translations/es.json +++ b/homeassistant/components/iaqualink/translations/es.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Contrase\u00f1a", - "username": "Usuario / correo electr\u00f3nico" + "username": "Usuario" }, "description": "Por favor, introduzca el nombre de usuario y la contrase\u00f1a de su cuenta de iAqualink.", "title": "Con\u00e9ctese a iAqualink" diff --git a/homeassistant/components/icloud/translations/no.json b/homeassistant/components/icloud/translations/no.json index 021ebd1d71b..9805c769a67 100644 --- a/homeassistant/components/icloud/translations/no.json +++ b/homeassistant/components/icloud/translations/no.json @@ -20,6 +20,7 @@ "user": { "data": { "password": "Passord", + "username": "E-post", "with_family": "Med familie" }, "description": "Fyll inn legitimasjonen din", diff --git a/homeassistant/components/icloud/translations/pl.json b/homeassistant/components/icloud/translations/pl.json index 20e3f8c2fb4..74d9fa42c27 100644 --- a/homeassistant/components/icloud/translations/pl.json +++ b/homeassistant/components/icloud/translations/pl.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Konto jest ju\u017c skonfigurowane.", - "no_device": "\u017badne z Twoich urz\u0105dze\u0144 nie ma aktywowanej funkcji \"Znajd\u017a m\u00f3j iPhone\"" + "no_device": "\u017badne z Twoich urz\u0105dze\u0144 nie ma aktywowanej funkcji \"Znajd\u017a m\u00f3j iPhone\"." }, "error": { "login": "B\u0142\u0105d logowania: sprawd\u017a adres e-mail i has\u0142o", diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 86a2242944d..555a268b62a 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -51,6 +51,7 @@ from .const import ( CONF_DB_NAME, CONF_DEFAULT_MEASUREMENT, CONF_HOST, + CONF_IGNORE_ATTRIBUTES, CONF_ORG, CONF_OVERRIDE_MEASUREMENT, CONF_PASSWORD, @@ -142,7 +143,10 @@ def validate_version_specific_config(conf: Dict) -> Dict: _CUSTOMIZE_ENTITY_SCHEMA = vol.Schema( - {vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string} + { + vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, + vol.Optional(CONF_IGNORE_ATTRIBUTES): vol.All(cv.ensure_list, [cv.string]), + } ) _INFLUX_BASE_SCHEMA = INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( @@ -154,6 +158,9 @@ _INFLUX_BASE_SCHEMA = INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( vol.Optional(CONF_TAGS_ATTRIBUTES, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(CONF_IGNORE_ATTRIBUTES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(CONF_COMPONENT_CONFIG, default={}): vol.Schema( {cv.entity_id: _CUSTOMIZE_ENTITY_SCHEMA} ), @@ -182,6 +189,7 @@ def _generate_event_to_json(conf: Dict) -> Callable[[Dict], str]: tags_attributes = conf.get(CONF_TAGS_ATTRIBUTES) default_measurement = conf.get(CONF_DEFAULT_MEASUREMENT) override_measurement = conf.get(CONF_OVERRIDE_MEASUREMENT) + global_ignore_attributes = set(conf[CONF_IGNORE_ATTRIBUTES]) component_config = EntityValues( conf[CONF_COMPONENT_CONFIG], conf[CONF_COMPONENT_CONFIG_DOMAIN], @@ -211,9 +219,8 @@ def _generate_event_to_json(conf: Dict) -> Callable[[Dict], str]: _include_state = True include_uom = True - measurement = component_config.get(state.entity_id).get( - CONF_OVERRIDE_MEASUREMENT - ) + entity_config = component_config.get(state.entity_id) + measurement = entity_config.get(CONF_OVERRIDE_MEASUREMENT) if measurement in (None, ""): if override_measurement: measurement = override_measurement @@ -241,10 +248,14 @@ def _generate_event_to_json(conf: Dict) -> Callable[[Dict], str]: if _include_value: json[INFLUX_CONF_FIELDS][INFLUX_CONF_VALUE] = _state_as_value + ignore_attributes = set(entity_config.get(CONF_IGNORE_ATTRIBUTES, [])) + ignore_attributes.update(global_ignore_attributes) for key, value in state.attributes.items(): if key in tags_attributes: json[INFLUX_CONF_TAGS][key] = value - elif key != CONF_UNIT_OF_MEASUREMENT or include_uom: + elif ( + key != CONF_UNIT_OF_MEASUREMENT or include_uom + ) and key not in ignore_attributes: # If the key is already in fields if key in json[INFLUX_CONF_FIELDS]: key = f"{key}_" diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index 1c7a9a0bfaa..a9115c3fc68 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -28,6 +28,7 @@ CONF_COMPONENT_CONFIG = "component_config" CONF_COMPONENT_CONFIG_GLOB = "component_config_glob" CONF_COMPONENT_CONFIG_DOMAIN = "component_config_domain" CONF_RETRY_COUNT = "max_retries" +CONF_IGNORE_ATTRIBUTES = "ignore_attributes" CONF_LANGUAGE = "language" CONF_QUERIES = "queries" diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 1258e1031b4..0e2b559d5e4 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -86,7 +86,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class IPPDataUpdateCoordinator(DataUpdateCoordinator): +class IPPDataUpdateCoordinator(DataUpdateCoordinator[IPPPrinter]): """Class to manage fetching IPP data from single endpoint.""" def __init__( diff --git a/homeassistant/components/ipp/translations/no.json b/homeassistant/components/ipp/translations/no.json index 543deee14fa..4e94efe71c1 100644 --- a/homeassistant/components/ipp/translations/no.json +++ b/homeassistant/components/ipp/translations/no.json @@ -19,7 +19,7 @@ "data": { "base_path": "Relativ bane til skriveren", "host": "Vert", - "port": "Port", + "port": "", "ssl": "Skriveren st\u00f8tter kommunikasjon over SSL/TLS", "verify_ssl": "Skriveren bruker et riktig SSL-sertifikat" }, diff --git a/homeassistant/components/ipp/translations/tr.json b/homeassistant/components/ipp/translations/tr.json new file mode 100644 index 00000000000..dbb14fe825e --- /dev/null +++ b/homeassistant/components/ipp/translations/tr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "zeroconf_confirm": { + "title": "Ke\u015ffedilen yaz\u0131c\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index cd75e88bb44..3e67e2639e2 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -5,12 +5,10 @@ import logging from pyiqvia import Client from pyiqvia.errors import InvalidZipError, IQVIAError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS +from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -18,13 +16,11 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from .config_flow import configured_instances from .const import ( CONF_ZIP_CODE, DATA_CLIENT, DATA_LISTENER, DOMAIN, - SENSORS, TOPIC_DATA_UPDATE, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, @@ -56,23 +52,6 @@ DATA_CONFIG = "config" DEFAULT_ATTRIBUTION = "Data provided by IQVIA™" DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.deprecated(CONF_MONITORED_CONDITIONS, invalidation_version="0.114.0"), - vol.Schema( - { - vol.Required(CONF_ZIP_CODE): str, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(SENSORS) - ): vol.All(cv.ensure_list, [vol.In(SENSORS)]), - } - ), - ) - }, - extra=vol.ALLOW_EXTRA, -) - @callback def async_get_api_category(sensor_type): @@ -86,20 +65,6 @@ async def async_setup(hass, config): hass.data[DOMAIN][DATA_CLIENT] = {} hass.data[DOMAIN][DATA_LISTENER] = {} - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - if conf[CONF_ZIP_CODE] in configured_instances(hass): - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - return True @@ -107,6 +72,12 @@ async def async_setup_entry(hass, config_entry): """Set up IQVIA as config entry.""" websession = aiohttp_client.async_get_clientsession(hass) + if not config_entry.unique_id: + # If the config entry doesn't already have a unique ID, set one: + hass.config_entries.async_update_entry( + config_entry, **{"unique_id": config_entry.data[CONF_ZIP_CODE]} + ) + iqvia = IQVIAData(hass, Client(config_entry.data[CONF_ZIP_CODE], websession)) try: diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py index 6a57f0f24d4..e43c61985d6 100644 --- a/homeassistant/components/iqvia/config_flow.py +++ b/homeassistant/components/iqvia/config_flow.py @@ -1,28 +1,15 @@ """Config flow to configure the IQVIA component.""" - -from collections import OrderedDict - from pyiqvia import Client from pyiqvia.errors import InvalidZipError import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import CONF_ZIP_CODE, DOMAIN +from .const import CONF_ZIP_CODE, DOMAIN # pylint:disable=unused-import -@callback -def configured_instances(hass): - """Return a set of configured IQVIA instances.""" - return { - entry.data[CONF_ZIP_CODE] for entry in hass.config_entries.async_entries(DOMAIN) - } - - -@config_entries.HANDLERS.register(DOMAIN) -class IQVIAFlowHandler(config_entries.ConfigFlow): +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle an IQVIA config flow.""" VERSION = 1 @@ -30,34 +17,25 @@ class IQVIAFlowHandler(config_entries.ConfigFlow): def __init__(self): """Initialize the config flow.""" - self.data_schema = OrderedDict() - self.data_schema[vol.Required(CONF_ZIP_CODE)] = str - - async def _show_form(self, errors=None): - """Show the form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema(self.data_schema), - errors=errors if errors else {}, - ) - - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) + self.data_schema = vol.Schema({vol.Required(CONF_ZIP_CODE): str}) async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" if not user_input: - return await self._show_form() + return self.async_show_form(step_id="user", data_schema=self.data_schema) - if user_input[CONF_ZIP_CODE] in configured_instances(self.hass): - return await self._show_form({CONF_ZIP_CODE: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_ZIP_CODE]) + self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) try: Client(user_input[CONF_ZIP_CODE], websession) except InvalidZipError: - return await self._show_form({CONF_ZIP_CODE: "invalid_zip_code"}) + return self.async_show_form( + step_id="user", + data_schema=self.data_schema, + errors={CONF_ZIP_CODE: "invalid_zip_code"}, + ) return self.async_create_entry(title=user_input[CONF_ZIP_CODE], data=user_input) diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 5d880888ef5..1f862bb1bbf 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,6 +3,6 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.19.0", "pyiqvia==0.2.1"], + "requirements": ["numpy==1.19.1", "pyiqvia==0.2.1"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json index efc9582e20a..b0d82430ef7 100644 --- a/homeassistant/components/iqvia/strings.json +++ b/homeassistant/components/iqvia/strings.json @@ -4,12 +4,16 @@ "user": { "title": "IQVIA", "description": "Fill out your U.S. or Canadian ZIP code.", - "data": { "zip_code": "ZIP Code" } + "data": { + "zip_code": "ZIP Code" + } } }, "error": { - "identifier_exists": "ZIP code already registered", "invalid_zip_code": "ZIP code is invalid" + }, + "abort": { + "already_configured": "This ZIP code has already been configured." } } } diff --git a/homeassistant/components/iqvia/translations/cs.json b/homeassistant/components/iqvia/translations/cs.json new file mode 100644 index 00000000000..eb0fdc62f01 --- /dev/null +++ b/homeassistant/components/iqvia/translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Toto PS\u010c ji\u017e bylo nakonfigurov\u00e1no." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/translations/en.json b/homeassistant/components/iqvia/translations/en.json index 63ffc145594..6c96b78c854 100644 --- a/homeassistant/components/iqvia/translations/en.json +++ b/homeassistant/components/iqvia/translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "This ZIP code has already been configured." + }, "error": { "identifier_exists": "ZIP code already registered", "invalid_zip_code": "ZIP code is invalid" diff --git a/homeassistant/components/iqvia/translations/es.json b/homeassistant/components/iqvia/translations/es.json index 0fbc0ecceb0..9288503ed60 100644 --- a/homeassistant/components/iqvia/translations/es.json +++ b/homeassistant/components/iqvia/translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Este c\u00f3digo postal ya ha sido configurado." + }, "error": { "identifier_exists": "C\u00f3digo postal ya registrado", "invalid_zip_code": "El c\u00f3digo postal no es v\u00e1lido" diff --git a/homeassistant/components/iqvia/translations/no.json b/homeassistant/components/iqvia/translations/no.json index 37fb766ee36..9f130f52403 100644 --- a/homeassistant/components/iqvia/translations/no.json +++ b/homeassistant/components/iqvia/translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne postnummeren er allerede konfigurert." + }, "error": { "identifier_exists": "Postnummer er allerede registrert", "invalid_zip_code": "Postnummeret er ugyldig" diff --git a/homeassistant/components/iqvia/translations/ru.json b/homeassistant/components/iqvia/translations/ru.json index 69b1bd3745e..d7b868acad3 100644 --- a/homeassistant/components/iqvia/translations/ru.json +++ b/homeassistant/components/iqvia/translations/ru.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u044d\u0442\u0438\u043c \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u043c \u0438\u043d\u0434\u0435\u043a\u0441\u043e\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, "error": { "identifier_exists": "\u041f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d.", "invalid_zip_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441." diff --git a/homeassistant/components/isy994/translations/no.json b/homeassistant/components/isy994/translations/no.json index 7864a6916cd..da5b2fe3711 100644 --- a/homeassistant/components/isy994/translations/no.json +++ b/homeassistant/components/isy994/translations/no.json @@ -4,15 +4,19 @@ "already_configured": "Enheten er allerede konfigurert" }, "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", "invalid_host": "Vertsoppf\u00f8ringen var ikke i fullstendig URL-format, for eksempel http://192.168.10.100:80", - "unknown": "[%key:common::config_flow::error::unknown%" + "unknown": "Uventet feil" }, "flow_title": "Universelle enheter ISY994 {name} ({host})", "step": { "user": { "data": { "host": "URL", - "tls": "TLS-versjonen av ISY-kontrolleren." + "password": "Passord", + "tls": "TLS-versjonen av ISY-kontrolleren.", + "username": "Brukernavn" }, "description": "Vertsoppf\u00f8ringen m\u00e5 v\u00e6re i fullstendig URL-format, for eksempel http://192.168.10.100:80", "title": "Koble til ISY994" diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 69c005b345b..cd14d1cadcf 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -26,6 +26,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_EXCLUDE, PRECISION_HALVES, + PRECISION_TENTHS, TEMP_CELSIUS, ) from homeassistant.core import callback @@ -250,7 +251,7 @@ class ControllerDevice(ClimateEntity): @property def precision(self) -> float: """Return the precision of the system.""" - return PRECISION_HALVES + return PRECISION_TENTHS @property def device_state_attributes(self): @@ -266,7 +267,7 @@ class ControllerDevice(ClimateEntity): self.hass, self._controller.temp_setpoint, self.temperature_unit, - self.precision, + PRECISION_HALVES, ), } @@ -494,7 +495,7 @@ class ZoneDevice(ClimateEntity): @property def precision(self): """Return the precision of the system.""" - return PRECISION_HALVES + return PRECISION_TENTHS @property def hvac_mode(self): diff --git a/homeassistant/components/konnected/translations/cs.json b/homeassistant/components/konnected/translations/cs.json index 814e0c63418..df1519035c6 100644 --- a/homeassistant/components/konnected/translations/cs.json +++ b/homeassistant/components/konnected/translations/cs.json @@ -8,5 +8,15 @@ } } } + }, + "options": { + "step": { + "options_io_ext": { + "data": { + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/konnected/translations/es.json b/homeassistant/components/konnected/translations/es.json index 0b3bff1ff92..f27fe036fa7 100644 --- a/homeassistant/components/konnected/translations/es.json +++ b/homeassistant/components/konnected/translations/es.json @@ -20,8 +20,8 @@ }, "user": { "data": { - "host": "Direcci\u00f3n IP del dispositivo Konnected", - "port": "Puerto del dispositivo Konnected" + "host": "Direcci\u00f3n IP", + "port": "Puerto" }, "description": "Introduzca la informaci\u00f3n del host de su panel Konnected." } @@ -32,9 +32,7 @@ "not_konn_panel": "No es un dispositivo Konnected.io reconocido" }, "error": { - "bad_host": "URL del host de la API de invalidaci\u00f3n no v\u00e1lida", - "one": "", - "other": "otros" + "bad_host": "URL del host de la API de invalidaci\u00f3n no v\u00e1lida" }, "step": { "options_binary": { @@ -101,7 +99,7 @@ "pause": "Pausa entre pulsos (ms) (opcional)", "repeat": "Tiempos de repetici\u00f3n (-1 = infinito) (opcional)" }, - "description": "Por favor, seleccione las opciones de salida para {zone}", + "description": "Selecciona las opciones de salida para {zone}: state {state}", "title": "Configurar la salida conmutable" } } diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index a10b46f89ce..448b4d3bb97 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -25,7 +25,7 @@ SUPPORT_LG = ( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the LG platform.""" if discovery_info is not None: - add_entities([LGDevice(discovery_info)], True) + add_entities([LGDevice(discovery_info)]) class LGDevice(MediaPlayerEntity): @@ -33,8 +33,10 @@ class LGDevice(MediaPlayerEntity): def __init__(self, discovery_info): """Initialize the LG speakers.""" - host = discovery_info.get("host") - port = discovery_info.get("port") + self._host = discovery_info.get("host") + self._port = discovery_info.get("port") + properties = discovery_info.get("properties") + self._uuid = properties.get("UUID") self._name = "" self._volume = 0 @@ -53,8 +55,17 @@ class LGDevice(MediaPlayerEntity): self._woofer_volume_max = 0 self._bass = 0 self._treble = 0 + self._device = None - self._device = temescal.temescal(host, port=port, callback=self.handle_event) + async def async_added_to_hass(self): + """Register the callback after hass is ready for it.""" + await self.hass.async_add_executor_job(self._connect) + + def _connect(self): + """Perform the actual devices setup.""" + self._device = temescal.temescal( + self._host, port=self._port, callback=self.handle_event + ) self.update() def handle_event(self, response): @@ -119,6 +130,11 @@ class LGDevice(MediaPlayerEntity): if equaliser >= len(temescal.equalisers): temescal.equalisers.append("unknown " + str(equaliser)) + @property + def unique_id(self): + """Return the device's unique ID.""" + return self._uuid + @property def name(self): """Return the name of the device.""" diff --git a/homeassistant/components/life360/translations/no.json b/homeassistant/components/life360/translations/no.json index 1abca20c9dc..dc1db8a9206 100644 --- a/homeassistant/components/life360/translations/no.json +++ b/homeassistant/components/life360/translations/no.json @@ -10,7 +10,8 @@ "error": { "invalid_credentials": "Ugyldig legitimasjon", "invalid_username": "Ugyldig brukernavn", - "unexpected": "Uventet feil under kommunikasjon med Life360-servern" + "unexpected": "Uventet feil under kommunikasjon med Life360-servern", + "user_already_configured": "Kontoen er allerede konfigurert" }, "step": { "user": { diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 0a3c087950e..14a870696ce 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -123,7 +123,12 @@ LIGHT_TURN_ON_SCHEMA = { PROFILE_SCHEMA = vol.Schema( - vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)) + vol.Any( + vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)), + vol.ExactSequence( + (str, cv.small_float, cv.small_float, cv.byte, cv.positive_int) + ), + ) ) _LOGGER = logging.getLogger(__name__) @@ -141,6 +146,8 @@ def preprocess_turn_on_alternatives(params): if profile is not None: params.setdefault(ATTR_XY_COLOR, profile[:2]) params.setdefault(ATTR_BRIGHTNESS, profile[2]) + if len(profile) > 3: + params.setdefault(ATTR_TRANSITION, profile[3]) color_name = params.pop(ATTR_COLOR_NAME, None) if color_name is not None: @@ -313,8 +320,22 @@ class Profiles: try: for rec in reader: - profile, color_x, color_y, brightness = PROFILE_SCHEMA(rec) - profiles[profile] = (color_x, color_y, brightness) + ( + profile, + color_x, + color_y, + brightness, + *transition, + ) = PROFILE_SCHEMA(rec) + + transition = transition[0] if transition else 0 + + profiles[profile] = ( + color_x, + color_y, + brightness, + transition, + ) except vol.MultipleInvalid as ex: _LOGGER.error( "Error parsing light profile from %s: %s", profile_path, ex diff --git a/homeassistant/components/linky/__init__.py b/homeassistant/components/linky/__init__.py deleted file mode 100644 index d21c007762c..00000000000 --- a/homeassistant/components/linky/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -"""The linky component.""" -import logging - -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import HomeAssistantType - -from .const import DEFAULT_TIMEOUT, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -ACCOUNT_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [ACCOUNT_SCHEMA]))}, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass, config): - """Set up Linky sensors from legacy config file.""" - - conf = config.get(DOMAIN) - if conf is None: - return True - - for linky_account_conf in conf: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=linky_account_conf.copy(), - ) - ) - - return True - - -async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): - """Set up Linky sensors.""" - # For backwards compat - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, unique_id=entry.data[CONF_USERNAME] - ) - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "sensor") - ) - return True - - -async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): - """Unload Linky sensors.""" - return await hass.config_entries.async_forward_entry_unload(entry, "sensor") diff --git a/homeassistant/components/linky/config_flow.py b/homeassistant/components/linky/config_flow.py deleted file mode 100644 index 88fa725cc4a..00000000000 --- a/homeassistant/components/linky/config_flow.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Config flow to configure the Linky integration.""" -import logging - -from pylinky.client import LinkyClient -from pylinky.exceptions import ( - PyLinkyAccessException, - PyLinkyEnedisException, - PyLinkyException, - PyLinkyWrongLoginException, -) -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME - -from .const import DEFAULT_TIMEOUT -from .const import DOMAIN # pylint: disable=unused-import - -_LOGGER = logging.getLogger(__name__) - - -class LinkyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - - def _show_setup_form(self, user_input=None, errors=None): - """Show the setup form to the user.""" - - if user_input is None: - user_input = {} - - return self.async_show_form( - step_id="user", - 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, - } - ), - errors=errors or {}, - ) - - async def async_step_user(self, user_input=None): - """Handle a flow initiated by the user.""" - errors = {} - - if user_input is None: - return self._show_setup_form(user_input, None) - - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - timeout = user_input.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - - # Check if already configured - if self.unique_id is None: - await self.async_set_unique_id(username) - self._abort_if_unique_id_configured() - - client = LinkyClient(username, password, None, timeout) - try: - await self.hass.async_add_executor_job(client.login) - await self.hass.async_add_executor_job(client.fetch_data) - except PyLinkyAccessException as exp: - _LOGGER.error(exp) - errors["base"] = "access" - return self._show_setup_form(user_input, errors) - except PyLinkyEnedisException as exp: - _LOGGER.error(exp) - errors["base"] = "enedis" - return self._show_setup_form(user_input, errors) - except PyLinkyWrongLoginException as exp: - _LOGGER.error(exp) - errors["base"] = "wrong_login" - return self._show_setup_form(user_input, errors) - except PyLinkyException as exp: - _LOGGER.error(exp) - errors["base"] = "unknown" - return self._show_setup_form(user_input, errors) - finally: - client.close_session() - - return self.async_create_entry( - title=username, - data={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_TIMEOUT: timeout, - }, - ) - - async def async_step_import(self, user_input=None): - """Import a config entry.""" - return await self.async_step_user(user_input) diff --git a/homeassistant/components/linky/const.py b/homeassistant/components/linky/const.py deleted file mode 100644 index e8e68867528..00000000000 --- a/homeassistant/components/linky/const.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Linky component constants.""" - -DOMAIN = "linky" - -DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/linky/manifest.json b/homeassistant/components/linky/manifest.json deleted file mode 100644 index 18ee74a78ce..00000000000 --- a/homeassistant/components/linky/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "linky", - "name": "Enedis Linky", - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/linky", - "requirements": ["pylinky==0.4.0"], - "codeowners": ["@Quentame"] -} diff --git a/homeassistant/components/linky/sensor.py b/homeassistant/components/linky/sensor.py deleted file mode 100644 index 7e9da01eb9a..00000000000 --- a/homeassistant/components/linky/sensor.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Support for Linky.""" -from datetime import timedelta -import json -import logging - -from pylinky.client import DAILY, MONTHLY, YEARLY, LinkyClient, PyLinkyException - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_USERNAME, - ENERGY_KILO_WATT_HOUR, -) -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import HomeAssistantType - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(hours=4) -ICON_ENERGY = "mdi:flash" -CONSUMPTION = "conso" -TIME = "time" -INDEX_CURRENT = -1 -INDEX_LAST = -2 -ATTRIBUTION = "Data provided by Enedis" - - -async def async_setup_entry( - hass: HomeAssistantType, entry: ConfigEntry, async_add_entities -) -> None: - """Add Linky entries.""" - account = LinkyAccount( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], entry.data[CONF_TIMEOUT] - ) - - await hass.async_add_executor_job(account.update_linky_data) - - sensors = [ - LinkySensor("Linky yesterday", account, DAILY, INDEX_LAST), - LinkySensor("Linky current month", account, MONTHLY, INDEX_CURRENT), - LinkySensor("Linky last month", account, MONTHLY, INDEX_LAST), - LinkySensor("Linky current year", account, YEARLY, INDEX_CURRENT), - LinkySensor("Linky last year", account, YEARLY, INDEX_LAST), - ] - - async_track_time_interval(hass, account.update_linky_data, SCAN_INTERVAL) - - async_add_entities(sensors, True) - - -class LinkyAccount: - """Representation of a Linky account.""" - - def __init__(self, username, password, timeout): - """Initialise the Linky account.""" - self._username = username - self._password = password - self._timeout = timeout - self._data = None - - def update_linky_data(self, event_time=None): - """Fetch new state data for the sensor.""" - client = LinkyClient(self._username, self._password, None, self._timeout) - try: - client.login() - client.fetch_data() - self._data = client.get_data() - _LOGGER.debug(json.dumps(self._data, indent=2)) - except PyLinkyException as exp: - _LOGGER.error(exp) - raise PlatformNotReady - finally: - client.close_session() - - @property - def username(self): - """Return the username.""" - return self._username - - @property - def data(self): - """Return the data.""" - return self._data - - -class LinkySensor(Entity): - """Representation of a sensor entity for Linky.""" - - def __init__(self, name, account: LinkyAccount, scale, when): - """Initialize the sensor.""" - self._name = name - self._account = account - self._scale = scale - self._when = when - self._username = account.username - self._time = None - self._consumption = None - self._unique_id = f"{self._username}_{scale}_{when}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_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._consumption - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return ENERGY_KILO_WATT_HOUR - - @property - def icon(self): - """Return the icon of the sensor.""" - return ICON_ENERGY - - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - "time": self._time, - CONF_USERNAME: self._username, - } - - @property - def device_info(self): - """Return device information.""" - return { - "identifiers": {(DOMAIN, self._username)}, - "name": "Linky meter", - "manufacturer": "Enedis", - } - - async def async_update(self) -> None: - """Retrieve the new data for the sensor.""" - if self._account.data is None: - return - - data = self._account.data[self._scale][self._when] - self._consumption = data[CONSUMPTION] - self._time = data[TIME] - - if self._scale is not YEARLY: - year_index = INDEX_CURRENT - if self._time.endswith("Dec"): - year_index = INDEX_LAST - self._time += f" {self._account.data[YEARLY][year_index][TIME]}" diff --git a/homeassistant/components/linky/strings.json b/homeassistant/components/linky/strings.json deleted file mode 100644 index dea7062d213..00000000000 --- a/homeassistant/components/linky/strings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Linky", - "description": "Enter your credentials", - "data": { - "username": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "access": "Could not access to Enedis.fr, please check your internet connection", - "enedis": "Enedis.fr answered with an error: please retry later (usually not between 11PM and 2AM)", - "wrong_login": "Login error: please check your email & password", - "unknown": "Unknown error: please retry later (usually not between 11PM and 2AM)" - }, - "abort": { - "already_configured": "Account already configured" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/bg.json b/homeassistant/components/linky/translations/bg.json deleted file mode 100644 index dd337013f59..00000000000 --- a/homeassistant/components/linky/translations/bg.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "config": { - "error": { - "access": "\u041d\u044f\u043c\u0430 \u0434\u043e\u0441\u0442\u044a\u043f \u0434\u043e Enedis.fr, \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u043e\u0441\u0442\u0442\u0430 \u0441\u0438", - "enedis": "Enedis.fr \u043e\u0442\u0433\u043e\u0432\u043e\u0440\u0438 \u0441 \u0433\u0440\u0435\u0448\u043a\u0430: \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e (\u043e\u0431\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u043e \u043d\u0435 \u043c\u0435\u0436\u0434\u0443 23:00 \u0438 02:00)", - "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430: \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e (\u043e\u0431\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u043e \u043d\u0435 \u043c\u0435\u0436\u0434\u0443 23:00 \u0438 02:00)", - "wrong_login": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0432\u043b\u0438\u0437\u0430\u043d\u0435: \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0438\u043c\u0435\u0439\u043b\u0430 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0441\u0438" - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "E-mail" - }, - "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043d\u0434\u0435\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438\u0442\u0435 \u0441\u0438 \u0434\u0430\u043d\u043d\u0438", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/ca.json b/homeassistant/components/linky/translations/ca.json deleted file mode 100644 index 954b873083a..00000000000 --- a/homeassistant/components/linky/translations/ca.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "El compte ja ha estat configurat" - }, - "error": { - "access": "No s'ha pogut accedir a Enedis.fr, comprova la teva connexi\u00f3 a Internet", - "enedis": "Enedis.fr ha respost amb un error: torna-ho a provar m\u00e9s tard (millo no entre les 23:00 i les 14:00)", - "unknown": "Error desconegut: torna-ho a provar m\u00e9s tard (millor no entre les 23:00 i les 14:00)", - "wrong_login": "Error d'inici de sessi\u00f3: comprova el teu correu electr\u00f2nic i la contrasenya" - }, - "step": { - "user": { - "data": { - "password": "Contrasenya", - "username": "Correu electr\u00f2nic" - }, - "description": "Introdueix les teves credencials", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/cs.json b/homeassistant/components/linky/translations/cs.json deleted file mode 100644 index 8f8c4648d5f..00000000000 --- a/homeassistant/components/linky/translations/cs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u00da\u010det je ji\u017e nakonfigurov\u00e1n" - }, - "step": { - "user": { - "data": { - "password": "Heslo", - "username": "E-mail" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/da.json b/homeassistant/components/linky/translations/da.json deleted file mode 100644 index 2fa885d1ffa..00000000000 --- a/homeassistant/components/linky/translations/da.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontoen er allerede konfigureret" - }, - "error": { - "access": "Kunne ikke f\u00e5 adgang til Enedis.fr, kontroller din internetforbindelse", - "enedis": "Enedis.fr svarede med en fejl: Pr\u00f8v igen senere (normalt ikke mellem 23:00 og 02:00)", - "unknown": "Ukendt fejl: Pr\u00f8v igen senere (normalt ikke mellem 23:00 og 02:00)", - "wrong_login": "Loginfejl: Kontroller din e-mail og adgangskode" - }, - "step": { - "user": { - "data": { - "password": "Adgangskode", - "username": "E-mail" - }, - "description": "Indtast dine legitimationsoplysninger", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/de.json b/homeassistant/components/linky/translations/de.json deleted file mode 100644 index c915ddf0881..00000000000 --- a/homeassistant/components/linky/translations/de.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto bereits konfiguriert" - }, - "error": { - "access": "Konnte nicht auf Enedis.fr zugreifen, \u00fcberpr\u00fcfe bitte die Internetverbindung", - "enedis": "Enedis.fr antwortete mit einem Fehler: wiederhole den Vorgang sp\u00e4ter (in der Regel nicht zwischen 23 Uhr und 2 Uhr morgens)", - "unknown": "Unbekannter Fehler: Wiederhole den Vorgang sp\u00e4ter (in der Regel nicht zwischen 23 Uhr und 2 Uhr morgens)", - "wrong_login": "Login-Fehler: Pr\u00fcfe bitte E-Mail & Passwort" - }, - "step": { - "user": { - "data": { - "password": "Passwort", - "username": "E-Mail-Adresse" - }, - "description": "Gib deine Zugangsdaten ein", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/en.json b/homeassistant/components/linky/translations/en.json deleted file mode 100644 index 512c0567444..00000000000 --- a/homeassistant/components/linky/translations/en.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account already configured" - }, - "error": { - "access": "Could not access to Enedis.fr, please check your internet connection", - "enedis": "Enedis.fr answered with an error: please retry later (usually not between 11PM and 2AM)", - "unknown": "Unknown error: please retry later (usually not between 11PM and 2AM)", - "wrong_login": "Login error: please check your email & password" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Email" - }, - "description": "Enter your credentials", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/es-419.json b/homeassistant/components/linky/translations/es-419.json deleted file mode 100644 index 58e44695fc8..00000000000 --- a/homeassistant/components/linky/translations/es-419.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "La cuenta ya ha sido configurada" - }, - "error": { - "access": "No se pudo acceder a Enedis.fr, compruebe su conexi\u00f3n a Internet.", - "enedis": "Enedis.fr respondi\u00f3 con un error: vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11 p.m. y las 2 a.m.)", - "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11 p.m. y las 2 a.m.)", - "wrong_login": "Error de inicio de sesi\u00f3n: por favor revise su direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a" - }, - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Correo electr\u00f3nico" - }, - "description": "Ingrese sus credenciales", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/es.json b/homeassistant/components/linky/translations/es.json deleted file mode 100644 index ef07dc2ca75..00000000000 --- a/homeassistant/components/linky/translations/es.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada" - }, - "error": { - "access": "No se pudo acceder a Enedis.fr, comprueba tu conexi\u00f3n a Internet", - "enedis": "Enedis.fr respondi\u00f3 con un error: vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11:00 y las 2 de la ma\u00f1ana)", - "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 23:00 y las 02:00 horas).", - "wrong_login": "Error de inicio de sesi\u00f3n: comprueba tu direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a" - }, - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Correo electr\u00f3nico" - }, - "description": "Introduzca sus credenciales", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/fr.json b/homeassistant/components/linky/translations/fr.json deleted file mode 100644 index 71dba36dbe8..00000000000 --- a/homeassistant/components/linky/translations/fr.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9" - }, - "error": { - "access": "Impossible d'acc\u00e9der \u00e0 Enedis.fr, merci de v\u00e9rifier votre connexion internet", - "enedis": "Erreur d'Enedis.fr: merci de r\u00e9essayer plus tard (pas entre 23h et 2h)", - "unknown": "Erreur inconnue: merci de r\u00e9essayer plus tard (pas entre 23h et 2h)", - "wrong_login": "Erreur de connexion: veuillez v\u00e9rifier votre e-mail et votre mot de passe" - }, - "step": { - "user": { - "data": { - "password": "Mot de passe", - "username": "Email" - }, - "description": "Entrez vos identifiants", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/hu.json b/homeassistant/components/linky/translations/hu.json deleted file mode 100644 index 9b450985375..00000000000 --- a/homeassistant/components/linky/translations/hu.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" - }, - "error": { - "access": "Nem siker\u00fclt el\u00e9rni az Enedis.fr webhelyet, ellen\u0151rizze internet-kapcsolat\u00e1t", - "enedis": "Az Enedis.fr hib\u00e1val v\u00e1laszolt: k\u00e9rj\u00fck, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra (\u00e1ltal\u00e1ban nem 23:00 \u00e9s 2:00 k\u00f6z\u00f6tt)", - "unknown": "Ismeretlen hiba: pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb (\u00e1ltal\u00e1ban nem 23:00 \u00e9s 2:00 \u00f3ra k\u00f6z\u00f6tt)" - }, - "step": { - "user": { - "data": { - "password": "Jelsz\u00f3", - "username": "E-mail" - }, - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/it.json b/homeassistant/components/linky/translations/it.json deleted file mode 100644 index ff5e226dcbe..00000000000 --- a/homeassistant/components/linky/translations/it.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account gi\u00e0 configurato" - }, - "error": { - "access": "Impossibile accedere a Enedis.fr, si prega di controllare la connessione internet", - "enedis": "Enedis.fr ha risposto con un errore: si prega di riprovare pi\u00f9 tardi (di solito non tra le 23:00 e le 02:00).", - "unknown": "Errore sconosciuto: riprova pi\u00f9 tardi (in genere non tra le 23:00 e le 02:00)", - "wrong_login": "Errore di accesso: si prega di controllare la tua E-mail e la password" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "E-mail" - }, - "description": "Inserisci le tue credenziali", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/ko.json b/homeassistant/components/linky/translations/ko.json deleted file mode 100644 index cd83aad724f..00000000000 --- a/homeassistant/components/linky/translations/ko.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." - }, - "error": { - "access": "Enedis.fr \uc5d0 \uc811\uc18d\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc778\ud130\ub137 \uc5f0\uacb0\uc744 \ud655\uc778\ud574\ubcf4\uc138\uc694", - "enedis": "Enedis.fr \uc774 \uc624\ub958\ub85c \uc751\ub2f5\ud588\uc2b5\ub2c8\ub2e4: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694 (\uc800\ub141 11\uc2dc \ubd80\ud130 \uc0c8\ubcbd 2\uc2dc\ub294 \ud53c\ud574\uc8fc\uc138\uc694)", - "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694 (\uc800\ub141 11\uc2dc \ubd80\ud130 \uc0c8\ubcbd 2\uc2dc\ub294 \ud53c\ud574\uc8fc\uc138\uc694)", - "wrong_login": "\ub85c\uadf8\uc778 \uc624\ub958: \uc774\uba54\uc77c \ubc0f \ube44\ubc00\ubc88\ud638\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694" - }, - "step": { - "user": { - "data": { - "password": "\ube44\ubc00\ubc88\ud638", - "username": "\uc774\uba54\uc77c" - }, - "description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/lb.json b/homeassistant/components/linky/translations/lb.json deleted file mode 100644 index 091a3b8d699..00000000000 --- a/homeassistant/components/linky/translations/lb.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kont ass scho konfigur\u00e9iert" - }, - "error": { - "access": "Keng Verbindung zu Enedis.fr, iwwerpr\u00e9ift d'Internet Verbindung", - "enedis": "Enedis.fr huet mat engem Feeler ge\u00e4ntwert: prob\u00e9iert sp\u00e9ider nach emol (normalerweis net t\u00ebscht 23h00 an 2h00)", - "unknown": "Onbekannte Feeler: prob\u00e9iert sp\u00e9ider nach emol (normalerweis net t\u00ebscht 23h00 an 2h00)", - "wrong_login": "Feeler beim Login: iwwerpr\u00e9ift \u00e4r E-Mail & Passwuert" - }, - "step": { - "user": { - "data": { - "password": "Passwuert", - "username": "E-Mail" - }, - "description": "F\u00ebllt \u00e4r Login Informatiounen aus", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/nl.json b/homeassistant/components/linky/translations/nl.json deleted file mode 100644 index 2c05353be3f..00000000000 --- a/homeassistant/components/linky/translations/nl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account al geconfigureerd" - }, - "error": { - "access": "Geen toegang tot Enedis.fr, controleer uw internetverbinding", - "enedis": "Enedis.fr antwoordde met een fout: probeer het later opnieuw (meestal niet tussen 23.00 en 02.00 uur)", - "unknown": "Onbekende fout: probeer het later opnieuw (meestal niet tussen 23.00 en 02.00 uur)", - "wrong_login": "Aanmeldingsfout: controleer uw e-mailadres en wachtwoord" - }, - "step": { - "user": { - "data": { - "password": "Wachtwoord", - "username": "E-mail" - }, - "description": "Voer uw gegevens in", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/no.json b/homeassistant/components/linky/translations/no.json deleted file mode 100644 index 5cf8ea2da34..00000000000 --- a/homeassistant/components/linky/translations/no.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontoen er allerede konfigurert" - }, - "error": { - "access": "Kunne ikke f\u00e5 tilgang til Enedis.fr, vennligst sjekk internettforbindelsen din", - "enedis": "Enedis.fr svarte med en feil: vennligst pr\u00f8v p\u00e5 nytt senere (vanligvis ikke mellom 23:00 og 02:00)", - "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere (vanligvis ikke mellom 23:00 og 02:00)", - "wrong_login": "Innloggingsfeil: vennligst sjekk e-postadressen og passordet ditt" - }, - "step": { - "user": { - "data": { - "password": "Passord", - "username": "E-post" - }, - "description": "Fyll inn legitimasjonen din", - "title": "" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/pl.json b/homeassistant/components/linky/translations/pl.json deleted file mode 100644 index 1fc09298fd7..00000000000 --- a/homeassistant/components/linky/translations/pl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane." - }, - "error": { - "access": "Nie mo\u017cna uzyska\u0107 dost\u0119pu do Enedis.fr, sprawd\u017a po\u0142\u0105czenie internetowe", - "enedis": "Enedis.fr odpowiedzia\u0142 b\u0142\u0119dem: spr\u00f3buj ponownie p\u00f3\u017aniej (zwykle nie mi\u0119dzy 23:00, a 2:00)", - "unknown": "Nieznany b\u0142\u0105d: spr\u00f3buj ponownie p\u00f3\u017aniej (zwykle nie mi\u0119dzy godzin\u0105 23:00, a 2:00)", - "wrong_login": "B\u0142\u0105d logowania: sprawd\u017a adres e-mail i has\u0142o" - }, - "step": { - "user": { - "data": { - "password": "Has\u0142o", - "username": "Adres e-mail" - }, - "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/pt-BR.json b/homeassistant/components/linky/translations/pt-BR.json deleted file mode 100644 index bf2bc7070ae..00000000000 --- a/homeassistant/components/linky/translations/pt-BR.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "error": { - "wrong_login": "Erro de Login: por favor, verifique seu e-mail e senha" - }, - "step": { - "user": { - "data": { - "password": "Senha", - "username": "E-mail" - }, - "description": "Insira suas credenciais", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/ru.json b/homeassistant/components/linky/translations/ru.json deleted file mode 100644 index 65e0269967a..00000000000 --- a/homeassistant/components/linky/translations/ru.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." - }, - "error": { - "access": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a Enedis.fr, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0443.", - "enedis": "Enedis.fr \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b \u043e\u0442\u0432\u0435\u0442 \u0441 \u043e\u0448\u0438\u0431\u043a\u043e\u0439: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 (\u043d\u0435 \u0432 \u043f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043a\u0435 \u0441 23:00 \u043f\u043e 2:00).", - "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 (\u043d\u0435 \u0432 \u043f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043a\u0435 \u0441 23:00 \u043f\u043e 2:00).", - "wrong_login": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0432\u0445\u043e\u0434\u0430: \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b \u0438 \u043f\u0430\u0440\u043e\u043b\u044c." - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" - }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/sl.json b/homeassistant/components/linky/translations/sl.json deleted file mode 100644 index 3df56ac5bbb..00000000000 --- a/homeassistant/components/linky/translations/sl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Ra\u010dun \u017ee nastavljen" - }, - "error": { - "access": "Do Enedis.fr ni bilo mogo\u010de dostopati, preverite internetno povezavo", - "enedis": "Enedis.fr je odgovoril z napako: poskusite pozneje (ponavadi med 23. in 2. uro)", - "unknown": "Neznana napaka: Prosimo, poskusite pozneje (obi\u010dajno ne med 23. in 2. uro)", - "wrong_login": "Napaka pri prijavi: preverite svoj e-po\u0161tni naslov in geslo" - }, - "step": { - "user": { - "data": { - "password": "Geslo", - "username": "E-po\u0161tni naslov" - }, - "description": "Vnesite svoje poverilnice", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/sv.json b/homeassistant/components/linky/translations/sv.json deleted file mode 100644 index 2d8c2b7177a..00000000000 --- a/homeassistant/components/linky/translations/sv.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontot har redan konfigurerats." - }, - "error": { - "access": "Det gick inte att komma \u00e5t Enedis.fr, kontrollera din internetanslutning", - "enedis": "Enedis.fr svarade med ett fel: f\u00f6rs\u00f6k igen senare (vanligtvis inte mellan 23:00 och 02:00)", - "unknown": "Ok\u00e4nt fel: f\u00f6rs\u00f6k igen senare (vanligtvis inte mellan 23:00 och 02:00)", - "wrong_login": "Inloggningsfel: v\u00e4nligen kontrollera din e-post och l\u00f6senord" - }, - "step": { - "user": { - "data": { - "password": "L\u00f6senord", - "username": "E-post" - }, - "description": "Ange dina autentiseringsuppgifter", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/zh-Hans.json b/homeassistant/components/linky/translations/zh-Hans.json deleted file mode 100644 index 62138856078..00000000000 --- a/homeassistant/components/linky/translations/zh-Hans.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "error": { - "wrong_login": "\u767b\u5f55\u51fa\u9519\uff1a\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u5b50\u90ae\u7bb1\u548c\u5bc6\u7801" - }, - "step": { - "user": { - "data": { - "password": "\u5bc6\u7801", - "username": "\u7535\u5b50\u90ae\u7bb1" - }, - "description": "\u8f93\u5165\u60a8\u7684\u8eab\u4efd\u8ba4\u8bc1" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/zh-Hant.json b/homeassistant/components/linky/translations/zh-Hant.json deleted file mode 100644 index 7a28dd692f6..00000000000 --- a/homeassistant/components/linky/translations/zh-Hant.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" - }, - "error": { - "access": "\u7121\u6cd5\u8a2a\u554f Enedis.fr\uff0c\u8acb\u6aa2\u67e5\u60a8\u7684\u7db2\u969b\u7db2\u8def\u9023\u7dda", - "enedis": "Endis.fr \u56de\u5831\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66\uff08\u901a\u5e38\u907f\u958b\u591c\u9593 11 - \u51cc\u6668 2 \u9ede\u4e4b\u9593\uff09", - "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66\uff08\u901a\u5e38\u907f\u958b\u591c\u9593 11 - \u51cc\u6668 2 \u9ede\u4e4b\u9593\uff09", - "wrong_login": "\u767b\u5165\u932f\u8aa4\uff1a\u8acb\u78ba\u8a8d\u96fb\u5b50\u90f5\u4ef6\u8207\u5bc6\u78bc" - }, - "step": { - "user": { - "data": { - "password": "\u5bc6\u78bc", - "username": "\u96fb\u5b50\u90f5\u4ef6" - }, - "description": "\u8f38\u5165\u6191\u8b49", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index ac9c6b1ba3e..28f85cf92da 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -212,18 +212,16 @@ class LogbookView(HomeAssistantView): ) ) - return await hass.async_add_job(json_events) + return await hass.async_add_executor_job(json_events) -def humanify(hass, events, entity_attr_cache, prev_states=None): +def humanify(hass, events, entity_attr_cache): """Generate a converted list of events into Entry objects. Will try to group events if possible: - if 2+ sensor updates in GROUP_BY_MINUTES, show last - if Home Assistant stop and start happen in same minute call it restarted """ - if prev_states is None: - prev_states = {} # Group events in batches of GROUP_BY_MINUTES for _, g_events in groupby( @@ -270,12 +268,6 @@ def humanify(hass, events, entity_attr_cache, prev_states=None): if event.event_type == EVENT_STATE_CHANGED: entity_id = event.entity_id - - # Skip events that have not changed state - if entity_id in prev_states and prev_states[entity_id] == event.state: - continue - - prev_states[entity_id] = event.state domain = event.domain if ( @@ -385,16 +377,10 @@ def _get_events( .outerjoin(old_state, (States.old_state_id == old_state.state_id)) # The below filter, removes state change events that do not have # and old_state, new_state, or the old and - # new state are the same for v8 schema or later. + # new state. # - # If the events/states were stored before v8 schema, we relay on the - # prev_states dict to remove them. - # - # When all data is schema v8 or later, the check for EMPTY_JSON_OBJECT - # can be removed. .filter( (Events.event_type != EVENT_STATE_CHANGED) - | (Events.event_data != EMPTY_JSON_OBJECT) | ( (States.state_id.isnot(None)) & (old_state.state_id.isnot(None)) @@ -438,18 +424,12 @@ def _get_events( entity_filter | (Events.event_type != EVENT_STATE_CHANGED) ) - # When all data is schema v8 or later, prev_states can be removed - prev_states = {} - return list(humanify(hass, yield_events(query), entity_attr_cache, prev_states)) + return list(humanify(hass, yield_events(query), entity_attr_cache)) def _keep_event(hass, event, entities_filter): if event.event_type == EVENT_STATE_CHANGED: entity_id = event.entity_id - # Do not report on new entities - # Do not report on entity removal - if not event.has_old_and_new_state: - return False elif event.event_type in HOMEASSISTANT_EVENTS: entity_id = f"{HA_DOMAIN}." elif event.event_type in hass.data[DOMAIN] and ATTR_ENTITY_ID not in event.data: @@ -640,25 +620,6 @@ class LazyEventPartialState: ) return self._time_fired_isoformat - @property - def has_old_and_new_state(self): - """Check the json data to see if new_state and old_state is present without decoding.""" - # Delete this check once all states are saved in the v8 schema - # format or later (they have the old_state_id column). - - # New events in v8+ schema format - if self._row.event_data == EMPTY_JSON_OBJECT: - # Events are already pre-filtered in sql - # to exclude missing old and new state - # if they are in v8+ format - return True - - # Old events not in v8 schema format - return ( - '"old_state": {' in self._row.event_data - and '"new_state": {' in self._row.event_data - ) - class EntityAttributeCache: """A cache to lookup static entity_id attribute. @@ -684,9 +645,7 @@ class EntityAttributeCache: if current_state: # Try the current state as its faster than decoding the # attributes - self._cache[entity_id][attribute] = current_state.attributes.get( - attribute, None - ) + self._cache[entity_id][attribute] = current_state.attributes.get(attribute) else: # If the entity has been removed, decode the attributes # instead diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 4295e3bb367..b517dda1ba3 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -56,6 +56,16 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): """Return a unique identifier.""" return f"occupancygroup_{self.device_id}" + @property + def device_info(self): + """Return the device info. + + Sensor entities are aggregated from one or more physical + sensors by each room. Therefore, there shouldn't be devices + related to any sensor entities. + """ + return None # pylint: disable=useless-return + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 6e3717481cf..62d53b17c47 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "media_extractor", "name": "Media Extractor", "documentation": "https://www.home-assistant.io/integrations/media_extractor", - "requirements": ["youtube_dl==2020.06.16.1"], + "requirements": ["youtube_dl==2020.07.28"], "dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/melcloud/translations/es.json b/homeassistant/components/melcloud/translations/es.json index 9dbb9d4f1f6..caba17be17a 100644 --- a/homeassistant/components/melcloud/translations/es.json +++ b/homeassistant/components/melcloud/translations/es.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "password": "Contrase\u00f1a de MELCloud.", - "username": "Correo electr\u00f3nico utilizado para iniciar sesi\u00f3n en MELCloud." + "password": "Contrase\u00f1a", + "username": "Correo electr\u00f3nico" }, "description": "Con\u00e9ctate usando tu cuenta de MELCloud.", "title": "Con\u00e9ctese a MELCloud" diff --git a/homeassistant/components/melcloud/translations/no.json b/homeassistant/components/melcloud/translations/no.json index fcdc00168eb..e96fdb171e7 100644 --- a/homeassistant/components/melcloud/translations/no.json +++ b/homeassistant/components/melcloud/translations/no.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "password": "MELCloud passord.", - "username": "E-post som blir brukt til \u00e5 logge inn p\u00e5 MELCloud." + "password": "Passord", + "username": "E-post" }, "description": "Koble til ved hjelp av MELCloud-kontoen din.", "title": "Koble til MELCloud" diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index f4f32a5097f..a68a8223ba5 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -3,6 +3,6 @@ "name": "Meteorologisk institutt (Met.no)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/met", - "requirements": ["pyMetno==0.5.1"], + "requirements": ["pyMetno==0.7.0"], "codeowners": ["@danielhiversen"] } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 6523efa0eb7..a1bcc360623 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -19,7 +19,6 @@ from homeassistant.const import ( PRESSURE_INHG, TEMP_CELSIUS, ) -from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_call_later @@ -37,7 +36,7 @@ ATTRIBUTION = ( ) DEFAULT_NAME = "Met.no" -URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/" +URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/classic" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -82,50 +81,43 @@ class MetWeather(WeatherEntity): self._unsub_fetch_data = None self._weather_data = None self._current_weather_data = {} + self._coordinates = {} self._forecast_data = None async def async_added_to_hass(self): """Start fetching data.""" - self._init_data() - await self._fetch_data() + await self._init_data() if self._config.get(CONF_TRACK_HOME): self._unsub_track_home = self.hass.bus.async_listen( - EVENT_CORE_CONFIG_UPDATE, self._core_config_updated + EVENT_CORE_CONFIG_UPDATE, self._init_data ) - @callback - def _init_data(self): - """Initialize a data object.""" - conf = self._config - + async def _init_data(self, _event=None): + """Initialize and fetch data object.""" if self.track_home: latitude = self.hass.config.latitude longitude = self.hass.config.longitude elevation = self.hass.config.elevation else: + conf = self._config latitude = conf[CONF_LATITUDE] longitude = conf[CONF_LONGITUDE] elevation = conf[CONF_ELEVATION] if not self._is_metric: - elevation = int( - round(convert_distance(elevation, LENGTH_FEET, LENGTH_METERS)) - ) + elevation = convert_distance(elevation, LENGTH_FEET, LENGTH_METERS) coordinates = { - "lat": str(latitude), - "lon": str(longitude), - "msl": str(elevation), + "lat": latitude, + "lon": longitude, + "msl": elevation, } + if coordinates == self._coordinates: + return + self._coordinates = coordinates + self._weather_data = metno.MetWeatherData( coordinates, async_get_clientsession(self.hass), URL ) - - async def _core_config_updated(self, _event): - """Handle core config updated.""" - self._init_data() - if self._unsub_fetch_data: - self._unsub_fetch_data() - self._unsub_fetch_data = None await self._fetch_data() async def will_remove_from_hass(self): @@ -140,6 +132,10 @@ class MetWeather(WeatherEntity): async def _fetch_data(self, *_): """Get the latest data from met.no.""" + if self._unsub_fetch_data: + self._unsub_fetch_data() + self._unsub_fetch_data = None + if not await self._weather_data.fetching_data(): # Retry in 15 to 20 minutes. minutes = 15 + randrange(6) @@ -155,10 +151,7 @@ class MetWeather(WeatherEntity): self._unsub_fetch_data = async_call_later( self.hass, randrange(55, 65) * 60, self._fetch_data ) - self._update() - def _update(self, *_): - """Get the latest data from Met.no.""" self._current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.DEFAULT_TIME_ZONE self._forecast_data = self._weather_data.get_forecast(time_zone) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index b7eda51b955..469c66ad79f 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -1,22 +1,31 @@ """Support for Meteo-France weather data.""" import asyncio -import datetime +from datetime import timedelta import logging -from meteofrance.client import meteofranceClient, meteofranceError -from vigilancemeteo import VigilanceMeteoError, VigilanceMeteoFranceProxy +from meteofrance.client import MeteoFranceClient import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_CITY, DOMAIN, PLATFORMS +from .const import ( + CONF_CITY, + COORDINATOR_ALERT, + COORDINATOR_FORECAST, + COORDINATOR_RAIN, + DOMAIN, + PLATFORMS, +) _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(minutes=5) +SCAN_INTERVAL_RAIN = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(minutes=15) CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string}) @@ -28,15 +37,14 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up Meteo-France from legacy config file.""" - conf = config.get(DOMAIN) - if conf is None: + if not conf: return True for city_conf in conf: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf.copy() + DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf ) ) @@ -47,38 +55,134 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool """Set up an Meteo-France account from a config entry.""" hass.data.setdefault(DOMAIN, {}) - # Weather alert - weather_alert_client = VigilanceMeteoFranceProxy() - try: - await hass.async_add_executor_job(weather_alert_client.update_data) - except VigilanceMeteoError as exp: - _LOGGER.error( - "Unexpected error when creating the vigilance_meteoFrance proxy: %s ", exp + latitude = entry.data.get(CONF_LATITUDE) + + client = MeteoFranceClient() + # Migrate from previous config + if not latitude: + places = await hass.async_add_executor_job( + client.search_places, entry.data[CONF_CITY] + ) + hass.config_entries.async_update_entry( + entry, + title=f"{places[0]}", + data={ + CONF_LATITUDE: places[0].latitude, + CONF_LONGITUDE: places[0].longitude, + }, ) - return False - hass.data[DOMAIN]["weather_alert_client"] = weather_alert_client - # Weather - city = entry.data[CONF_CITY] - try: - client = await hass.async_add_executor_job(meteofranceClient, city) - except meteofranceError as exp: - _LOGGER.error("Unexpected error when creating the meteofrance proxy: %s", exp) - return False + latitude = entry.data[CONF_LATITUDE] + longitude = entry.data[CONF_LONGITUDE] - hass.data[DOMAIN][city] = MeteoFranceUpdater(client) - await hass.async_add_executor_job(hass.data[DOMAIN][city].update) + async def _async_update_data_forecast_forecast(): + """Fetch data from API endpoint.""" + return await hass.async_add_job(client.get_forecast, latitude, longitude) + + async def _async_update_data_rain(): + """Fetch data from API endpoint.""" + return await hass.async_add_job(client.get_rain, latitude, longitude) + + async def _async_update_data_alert(): + """Fetch data from API endpoint.""" + return await hass.async_add_job( + client.get_warning_current_phenomenoms, department, 0, True + ) + + coordinator_forecast = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France forecast for city {entry.title}", + update_method=_async_update_data_forecast_forecast, + update_interval=SCAN_INTERVAL, + ) + coordinator_rain = None + coordinator_alert = None + + # Fetch initial data so we have data when entities subscribe + await coordinator_forecast.async_refresh() + + if not coordinator_forecast.last_update_success: + raise ConfigEntryNotReady + + # Check if rain forecast is available. + if coordinator_forecast.data.position.get("rain_product_available") == 1: + coordinator_rain = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France rain for city {entry.title}", + update_method=_async_update_data_rain, + update_interval=SCAN_INTERVAL_RAIN, + ) + await coordinator_rain.async_refresh() + + if not coordinator_rain.last_update_success: + raise ConfigEntryNotReady + else: + _LOGGER.warning( + "1 hour rain forecast not available. %s is not in covered zone", + entry.title, + ) + + department = coordinator_forecast.data.position.get("dept") + _LOGGER.debug( + "Department corresponding to %s is %s", entry.title, department, + ) + if department: + if not hass.data[DOMAIN].get(department): + coordinator_alert = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France alert for department {department}", + update_method=_async_update_data_alert, + update_interval=SCAN_INTERVAL, + ) + + await coordinator_alert.async_refresh() + + if not coordinator_alert.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][department] = True + else: + _LOGGER.warning( + "Weather alert for department %s won't be added with city %s, as it has already been added within another city", + department, + entry.title, + ) + else: + _LOGGER.warning( + "Weather alert not available: The city %s is not in France or Andorre.", + entry.title, + ) + + hass.data[DOMAIN][entry.entry_id] = { + COORDINATOR_FORECAST: coordinator_forecast, + COORDINATOR_RAIN: coordinator_rain, + COORDINATOR_ALERT: coordinator_alert, + } for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, platform) ) - _LOGGER.debug("meteo_france sensor platform loaded for %s", city) + return True async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): """Unload a config entry.""" + if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]: + + department = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR_FORECAST + ].data.position.get("dept") + hass.data[DOMAIN][department] = False + _LOGGER.debug( + "Weather alert for depatment %s unloaded and released. It can be added now by another city.", + department, + ) + unload_ok = all( await asyncio.gather( *[ @@ -88,29 +192,8 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): ) ) if unload_ok: - hass.data[DOMAIN].pop(entry.data[CONF_CITY]) + hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) return unload_ok - - -class MeteoFranceUpdater: - """Update data from Meteo-France.""" - - def __init__(self, client: meteofranceClient): - """Initialize the data object.""" - self._client = client - - def get_data(self): - """Get the latest data from Meteo-France.""" - return self._client.get_data() - - @Throttle(SCAN_INTERVAL) - def update(self): - """Get the latest data from Meteo-France.""" - - try: - self._client.update() - except meteofranceError as exp: - _LOGGER.error( - "Unexpected error when updating the meteofrance proxy: %s", exp - ) diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index c7673020360..73b1ea41089 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -1,12 +1,15 @@ """Config flow to configure the Meteo-France integration.""" import logging -from meteofrance.client import meteofranceClient, meteofranceError +from meteofrance.client import MeteoFranceClient import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.core import callback -from .const import CONF_CITY +from .const import CONF_CITY, FORECAST_MODE, FORECAST_MODE_DAILY from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -18,7 +21,13 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def _show_setup_form(self, user_input=None, errors=None): + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return MeteoFranceOptionsFlowHandler(config_entry) + + async def _show_setup_form(self, user_input=None, errors=None): """Show the setup form to the user.""" if user_input is None: @@ -37,26 +46,89 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is None: - return self._show_setup_form(user_input, errors) + return await self._show_setup_form(user_input, errors) city = user_input[CONF_CITY] # Might be a city name or a postal code - city_name = None + latitude = user_input.get(CONF_LATITUDE) + longitude = user_input.get(CONF_LONGITUDE) - try: - client = await self.hass.async_add_executor_job(meteofranceClient, city) - city_name = client.get_data()["name"] - except meteofranceError as exp: - _LOGGER.error( - "Unexpected error when creating the meteofrance proxy: %s", exp - ) - return self.async_abort(reason="unknown") + if not latitude: + client = MeteoFranceClient() + places = await self.hass.async_add_executor_job(client.search_places, city) + _LOGGER.debug("places search result: %s", places) + if not places: + errors[CONF_CITY] = "empty" + return await self._show_setup_form(user_input, errors) + + return await self.async_step_cities(places=places) # Check if already configured - await self.async_set_unique_id(city_name) + await self.async_set_unique_id(f"{latitude}, {longitude}") self._abort_if_unique_id_configured() - return self.async_create_entry(title=city_name, data={CONF_CITY: city}) + return self.async_create_entry( + title=city, data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + ) async def async_step_import(self, user_input): """Import a config entry.""" return await self.async_step_user(user_input) + + async def async_step_cities(self, user_input=None, places=None): + """Step where the user choose the city from the API search results.""" + if places and len(places) > 1 and self.source != SOURCE_IMPORT: + places_for_form = {} + for place in places: + places_for_form[_build_place_key(place)] = f"{place}" + + return await self._show_cities_form(places_for_form) + # for import and only 1 city in the search result + if places and not user_input: + user_input = {CONF_CITY: _build_place_key(places[0])} + + city_infos = user_input.get(CONF_CITY).split(";") + return await self.async_step_user( + { + CONF_CITY: city_infos[0], + CONF_LATITUDE: city_infos[1], + CONF_LONGITUDE: city_infos[2], + } + ) + + async def _show_cities_form(self, cities): + """Show the form to choose the city.""" + return self.async_show_form( + step_id="cities", + data_schema=vol.Schema( + {vol.Required(CONF_CITY): vol.All(vol.Coerce(str), vol.In(cities))} + ), + ) + + +class MeteoFranceOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_MODE, + default=self.config_entry.options.get( + CONF_MODE, FORECAST_MODE_DAILY + ), + ): vol.In(FORECAST_MODE) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +def _build_place_key(place) -> str: + return f"{place};{place.latitude};{place.longitude}" diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 2edbf980f36..d1decb54078 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,90 +1,127 @@ """Meteo-France component constants.""" from homeassistant.const import ( + PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - TIME_MINUTES, UNIT_PERCENTAGE, ) DOMAIN = "meteo_france" PLATFORMS = ["sensor", "weather"] +COORDINATOR_FORECAST = "coordinator_forecast" +COORDINATOR_RAIN = "coordinator_rain" +COORDINATOR_ALERT = "coordinator_alert" ATTRIBUTION = "Data provided by Météo-France" CONF_CITY = "city" +FORECAST_MODE_HOURLY = "hourly" +FORECAST_MODE_DAILY = "daily" +FORECAST_MODE = [FORECAST_MODE_HOURLY, FORECAST_MODE_DAILY] -DEFAULT_WEATHER_CARD = True +ATTR_NEXT_RAIN_1_HOUR_FORECAST = "1_hour_forecast" + +ENTITY_NAME = "name" +ENTITY_UNIT = "unit" +ENTITY_ICON = "icon" +ENTITY_CLASS = "device_class" +ENTITY_ENABLE = "enable" +ENTITY_API_DATA_PATH = "data_path" -SENSOR_TYPE_NAME = "name" -SENSOR_TYPE_UNIT = "unit" -SENSOR_TYPE_ICON = "icon" -SENSOR_TYPE_CLASS = "device_class" SENSOR_TYPES = { + "pressure": { + ENTITY_NAME: "Pressure", + ENTITY_UNIT: PRESSURE_HPA, + ENTITY_ICON: "mdi:gauge", + ENTITY_CLASS: "pressure", + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:sea_level", + }, "rain_chance": { - SENSOR_TYPE_NAME: "Rain chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-rainy", - SENSOR_TYPE_CLASS: None, - }, - "freeze_chance": { - SENSOR_TYPE_NAME: "Freeze chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:snowflake", - SENSOR_TYPE_CLASS: None, - }, - "thunder_chance": { - SENSOR_TYPE_NAME: "Thunder chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-lightning", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Rain chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-rainy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:rain:3h", }, "snow_chance": { - SENSOR_TYPE_NAME: "Snow chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-snowy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Snow chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-snowy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:snow:3h", }, - "weather": { - SENSOR_TYPE_NAME: "Weather", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:weather-partly-cloudy", - SENSOR_TYPE_CLASS: None, + "freeze_chance": { + ENTITY_NAME: "Freeze chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:snowflake", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:freezing", }, "wind_speed": { - SENSOR_TYPE_NAME: "Wind Speed", - SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, - SENSOR_TYPE_ICON: "mdi:weather-windy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Wind speed", + ENTITY_UNIT: SPEED_KILOMETERS_PER_HOUR, + ENTITY_ICON: "mdi:weather-windy", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:wind:speed", }, "next_rain": { - SENSOR_TYPE_NAME: "Next rain", - SENSOR_TYPE_UNIT: TIME_MINUTES, - SENSOR_TYPE_ICON: "mdi:weather-rainy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Next rain", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:weather-pouring", + ENTITY_CLASS: "timestamp", + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: None, }, "temperature": { - SENSOR_TYPE_NAME: "Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_ICON: "mdi:thermometer", - SENSOR_TYPE_CLASS: "temperature", + ENTITY_NAME: "Temperature", + ENTITY_UNIT: TEMP_CELSIUS, + ENTITY_ICON: "mdi:thermometer", + ENTITY_CLASS: "temperature", + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:T:value", }, "uv": { - SENSOR_TYPE_NAME: "UV", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:sunglasses", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "UV", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:sunglasses", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "today_forecast:uv", }, "weather_alert": { - SENSOR_TYPE_NAME: "Weather Alert", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:weather-cloudy-alert", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Weather alert", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:weather-cloudy-alert", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: None, + }, + "precipitation": { + ENTITY_NAME: "Daily precipitation", + ENTITY_UNIT: "mm", + ENTITY_ICON: "mdi:cup-water", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "today_forecast:precipitation:24h", + }, + "cloud": { + ENTITY_NAME: "Cloud cover", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-partly-cloudy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "current_forecast:clouds", }, } CONDITION_CLASSES = { "clear-night": ["Nuit Claire", "Nuit claire"], - "cloudy": ["Très nuageux"], + "cloudy": ["Très nuageux", "Couvert"], "fog": [ "Brume ou bancs de brouillard", "Brume", @@ -94,7 +131,13 @@ CONDITION_CLASSES = { "hail": ["Risque de grêle"], "lightning": ["Risque d'orages", "Orages"], "lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"], - "partlycloudy": ["Ciel voilé", "Ciel voilé nuit", "Éclaircies"], + "partlycloudy": [ + "Ciel voilé", + "Ciel voilé nuit", + "Éclaircies", + "Eclaircies", + "Peu nuageux", + ], "pouring": ["Pluie forte"], "rainy": [ "Bruine / Pluie faible", diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 5f12037e011..cd6f09246a6 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -3,6 +3,6 @@ "name": "Météo-France", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meteo_france", - "requirements": ["meteofrance==0.3.7", "vigilancemeteo==3.0.1"], - "codeowners": ["@victorcerutti", "@oncleben31", "@Quentame"] + "requirements": ["meteofrance-api==0.1.0"], + "codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index cf28b9ea558..39e33dafd65 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -1,168 +1,231 @@ """Support for Meteo-France raining forecast sensor.""" import logging -from meteofrance.client import meteofranceClient -from vigilancemeteo import DepartmentWeatherAlert, VigilanceMeteoFranceProxy +from meteofrance.helpers import ( + get_warning_text_status_from_indice_color, + readeable_phenomenoms_dict, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util from .const import ( + ATTR_NEXT_RAIN_1_HOUR_FORECAST, ATTRIBUTION, - CONF_CITY, + COORDINATOR_ALERT, + COORDINATOR_FORECAST, + COORDINATOR_RAIN, DOMAIN, - SENSOR_TYPE_CLASS, - SENSOR_TYPE_ICON, - SENSOR_TYPE_NAME, - SENSOR_TYPE_UNIT, + ENTITY_API_DATA_PATH, + ENTITY_CLASS, + ENTITY_ENABLE, + ENTITY_ICON, + ENTITY_NAME, + ENTITY_UNIT, SENSOR_TYPES, ) _LOGGER = logging.getLogger(__name__) -STATE_ATTR_FORECAST = "1h rain forecast" -STATE_ATTR_BULLETIN_TIME = "Bulletin date" - async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ) -> None: """Set up the Meteo-France sensor platform.""" - city = entry.data[CONF_CITY] - client = hass.data[DOMAIN][city] - weather_alert_client = hass.data[DOMAIN]["weather_alert_client"] + coordinator_forecast = hass.data[DOMAIN][entry.entry_id][COORDINATOR_FORECAST] + coordinator_rain = hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] + coordinator_alert = hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] - alert_watcher = None - datas = client.get_data() - # Check if a department code is available for this city. - if "dept" in datas: - try: - # If yes create the watcher DepartmentWeatherAlert object. - alert_watcher = await hass.async_add_executor_job( - DepartmentWeatherAlert, datas["dept"], weather_alert_client - ) - _LOGGER.info( - "Weather alert watcher added for %s in department %s", - city, - datas["dept"], - ) - except ValueError as exp: - _LOGGER.error( - "Unexpected error when creating the weather alert sensor for %s in department %s: %s", - city, - datas["dept"], - exp, - ) - else: - _LOGGER.warning( - "No 'dept' key found for '%s'. So weather alert information won't be available", - city, - ) - # Exit and don't create the sensor if no department code available. - return + entities = [] + for sensor_type in SENSOR_TYPES: + if sensor_type == "next_rain": + if coordinator_rain: + entities.append(MeteoFranceRainSensor(sensor_type, coordinator_rain)) + + elif sensor_type == "weather_alert": + if coordinator_alert: + entities.append(MeteoFranceAlertSensor(sensor_type, coordinator_alert)) + + elif sensor_type in ["rain_chance", "freeze_chance", "snow_chance"]: + if coordinator_forecast.data.probability_forecast: + entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) + else: + _LOGGER.warning( + "Sensor %s skipped for %s as data is missing in the API", + sensor_type, + coordinator_forecast.data.position["name"], + ) + + else: + entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) async_add_entities( - [ - MeteoFranceSensor(sensor_type, client, alert_watcher) - for sensor_type in SENSOR_TYPES - ], - True, + entities, False, ) class MeteoFranceSensor(Entity): """Representation of a Meteo-France sensor.""" - def __init__( - self, - sensor_type: str, - client: meteofranceClient, - alert_watcher: VigilanceMeteoFranceProxy, - ): + def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator): """Initialize the Meteo-France sensor.""" self._type = sensor_type - self._client = client - self._alert_watcher = alert_watcher - self._state = None - self._data = {} - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._data['name']} {SENSOR_TYPES[self._type][SENSOR_TYPE_NAME]}" + self.coordinator = coordinator + city_name = self.coordinator.data.position["name"] + self._name = f"{city_name} {SENSOR_TYPES[self._type][ENTITY_NAME]}" + self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}_{self._type}" @property def unique_id(self): - """Return the unique id of the sensor.""" - return self.name + """Return the unique id.""" + return self._unique_id + + @property + def name(self): + """Return the name.""" + return self._name @property def state(self): - """Return the state of the sensor.""" - return self._state + """Return the state.""" + path = SENSOR_TYPES[self._type][ENTITY_API_DATA_PATH].split(":") + data = getattr(self.coordinator.data, path[0]) - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - # Attributes for next_rain sensor. - if self._type == "next_rain" and "rain_forecast" in self._data: - return { - **{STATE_ATTR_FORECAST: self._data["rain_forecast"]}, - **self._data["next_rain_intervals"], - **{ATTR_ATTRIBUTION: ATTRIBUTION}, - } + # Specific case for probability forecast + if path[0] == "probability_forecast": + if len(path) == 3: + # This is a fix compared to other entitty as first index is always null in API result for unknown reason + value = _find_first_probability_forecast_not_null(data, path) + else: + value = data[0][path[1]] - # Attributes for weather_alert sensor. - if self._type == "weather_alert" and self._alert_watcher is not None: - return { - **{STATE_ATTR_BULLETIN_TIME: self._alert_watcher.bulletin_date}, - **self._alert_watcher.alerts_list, - ATTR_ATTRIBUTION: ATTRIBUTION, - } + # General case + else: + if len(path) == 3: + value = data[path[1]][path[2]] + else: + value = data[path[1]] - # Attributes for all other sensors. - return {ATTR_ATTRIBUTION: ATTRIBUTION} + if self._type == "wind_speed": + # convert API wind speed from m/s to km/h + value = round(value * 3.6) + return value @property def unit_of_measurement(self): """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_UNIT] + return SENSOR_TYPES[self._type][ENTITY_UNIT] @property def icon(self): """Return the icon.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_ICON] + return SENSOR_TYPES[self._type][ENTITY_ICON] @property def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_CLASS] + """Return the device class.""" + return SENSOR_TYPES[self._type][ENTITY_CLASS] - def update(self): - """Fetch new state data for the sensor.""" - try: - self._client.update() - self._data = self._client.get_data() + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return SENSOR_TYPES[self._type][ENTITY_ENABLE] - if self._type == "weather_alert": - if self._alert_watcher is not None: - self._alert_watcher.update_department_status() - self._state = self._alert_watcher.department_color - _LOGGER.debug( - "weather alert watcher for %s updated. Proxy have the status: %s", - self._data["name"], - self._alert_watcher.proxy.status, - ) - else: - _LOGGER.warning( - "No weather alert data for location %s", self._data["name"] - ) - else: - self._state = self._data[self._type] - except KeyError: - _LOGGER.error( - "No condition %s for location %s", self._type, self._data["name"] - ) - self._state = None + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def available(self): + """Return if state is available.""" + return self.coordinator.last_update_success + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_update(self): + """Only used by the generic entity update service.""" + if not self.enabled: + return + + await self.coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + +class MeteoFranceRainSensor(MeteoFranceSensor): + """Representation of a Meteo-France rain sensor.""" + + @property + def state(self): + """Return the state.""" + next_rain_date_locale = self.coordinator.data.next_rain_date_locale() + return ( + dt_util.as_local(next_rain_date_locale) if next_rain_date_locale else None + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_NEXT_RAIN_1_HOUR_FORECAST: [ + { + dt_util.as_local( + self.coordinator.data.timestamp_to_locale_time(item["dt"]) + ).strftime("%H:%M"): item["desc"] + } + for item in self.coordinator.data.forecast + ], + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + +class MeteoFranceAlertSensor(MeteoFranceSensor): + """Representation of a Meteo-France alert sensor.""" + + # pylint: disable=super-init-not-called + def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator): + """Initialize the Meteo-France sensor.""" + self._type = sensor_type + self.coordinator = coordinator + dept_code = self.coordinator.data.domain_id + self._name = f"{dept_code} {SENSOR_TYPES[self._type][ENTITY_NAME]}" + self._unique_id = self._name + + @property + def state(self): + """Return the state.""" + return get_warning_text_status_from_indice_color( + self.coordinator.data.get_domain_max_color() + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + **readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors), + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + +def _find_first_probability_forecast_not_null( + probability_forecast: list, path: list +) -> int: + """Search the first not None value in the first forecast elements.""" + for forecast in probability_forecast[0:3]: + if forecast[path[1]][path[2]] is not None: + return forecast[path[1]][path[2]] + + # Default return value if no value founded + return None diff --git a/homeassistant/components/meteo_france/strings.json b/homeassistant/components/meteo_france/strings.json index fc6e426b8d4..611d1ca054c 100644 --- a/homeassistant/components/meteo_france/strings.json +++ b/homeassistant/components/meteo_france/strings.json @@ -4,12 +4,33 @@ "user": { "title": "M\u00e9t\u00e9o-France", "description": "Enter the postal code (only for France, recommended) or city name", - "data": { "city": "City" } + "data": { + "city": "City" + } + }, + "cities": { + "title": "M\u00e9t\u00e9o-France", + "description": "Choose your city from the list", + "data": { + "city": "City" + } } }, + "error": { + "empty": "No result in city search: please check the city field" + }, "abort": { "already_configured": "City already configured", "unknown": "Unknown error: please retry later" } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Forecast mode" + } + } + } } -} +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/cs.json b/homeassistant/components/meteo_france/translations/cs.json new file mode 100644 index 00000000000..da3388bd558 --- /dev/null +++ b/homeassistant/components/meteo_france/translations/cs.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "empty": "\u017d\u00e1dn\u00fd v\u00fdsledek p\u0159i hled\u00e1n\u00ed m\u011bsta: zkontrolujte pros\u00edm pole m\u011bsta" + }, + "step": { + "cities": { + "data": { + "city": "M\u011bsto" + }, + "description": "Vyberte m\u011bsto ze seznamu" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Re\u017eim p\u0159edpov\u011bdi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/en.json b/homeassistant/components/meteo_france/translations/en.json index 7b161dcda07..979f705cc5b 100644 --- a/homeassistant/components/meteo_france/translations/en.json +++ b/homeassistant/components/meteo_france/translations/en.json @@ -4,7 +4,17 @@ "already_configured": "City already configured", "unknown": "Unknown error: please retry later" }, + "error": { + "empty": "No result in city search: please check the city field" + }, "step": { + "cities": { + "data": { + "city": "City" + }, + "description": "Choose your city from the list", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "City" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Forecast mode" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/es.json b/homeassistant/components/meteo_france/translations/es.json index 4c04a5d32c3..31d221eba09 100644 --- a/homeassistant/components/meteo_france/translations/es.json +++ b/homeassistant/components/meteo_france/translations/es.json @@ -4,7 +4,17 @@ "already_configured": "La ciudad ya est\u00e1 configurada", "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde" }, + "error": { + "empty": "No hay resultado en la b\u00fasqueda de la ciudad: por favor, comprueba el campo de la ciudad" + }, "step": { + "cities": { + "data": { + "city": "Ciudad" + }, + "description": "Elige tu ciudad de la lista", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "Ciudad" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Modo de pron\u00f3stico" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/it.json b/homeassistant/components/meteo_france/translations/it.json index 23b40164c7b..df5cf4a6375 100644 --- a/homeassistant/components/meteo_france/translations/it.json +++ b/homeassistant/components/meteo_france/translations/it.json @@ -4,7 +4,17 @@ "already_configured": "Citt\u00e0 gi\u00e0 configurata", "unknown": "Errore sconosciuto: riprovare pi\u00f9 tardi" }, + "error": { + "empty": "Nessun risultato nella ricerca della citt\u00e0: si prega di controllare il campo citt\u00e0" + }, "step": { + "cities": { + "data": { + "city": "Citt\u00e0" + }, + "description": "Scegli la tua citt\u00e0 dall'elenco", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "Citt\u00e0" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Modalit\u00e0 previsione" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/no.json b/homeassistant/components/meteo_france/translations/no.json index d4921d7e4e5..91eea1fcec7 100644 --- a/homeassistant/components/meteo_france/translations/no.json +++ b/homeassistant/components/meteo_france/translations/no.json @@ -4,7 +4,17 @@ "already_configured": "Byen er allerede konfigurert", "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere" }, + "error": { + "empty": "Ingen resultater i bys\u00f8k: vennligst sjekk byfeltet" + }, "step": { + "cities": { + "data": { + "city": "By" + }, + "description": "Velg din by fra listen", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "By" @@ -13,5 +23,14 @@ "title": "" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Prognosemodus" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/ru.json b/homeassistant/components/meteo_france/translations/ru.json index 47e2eda63af..ba0bf1df3c2 100644 --- a/homeassistant/components/meteo_france/translations/ru.json +++ b/homeassistant/components/meteo_france/translations/ru.json @@ -4,7 +4,17 @@ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c\u0438 \u0436\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043c\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435." }, + "error": { + "empty": "\u041d\u0435\u0442 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u043e\u0432 \u043f\u043e\u0438\u0441\u043a\u0430. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u043e\u043b\u0435 \"\u0413\u043e\u0440\u043e\u0434\"." + }, "step": { + "cities": { + "data": { + "city": "\u0413\u043e\u0440\u043e\u0434" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0433\u043e\u0440\u043e\u0434 \u0438\u0437 \u0441\u043f\u0438\u0441\u043a\u0430", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "\u0413\u043e\u0440\u043e\u0434" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "\u0420\u0435\u0436\u0438\u043c \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/zh-Hant.json b/homeassistant/components/meteo_france/translations/zh-Hant.json index afece58eef4..0179f5ad7d1 100644 --- a/homeassistant/components/meteo_france/translations/zh-Hant.json +++ b/homeassistant/components/meteo_france/translations/zh-Hant.json @@ -4,7 +4,17 @@ "already_configured": "\u57ce\u5e02\u5df2\u8a2d\u5b9a\u5b8c\u6210", "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66" }, + "error": { + "empty": "\u627e\u4e0d\u5230\u76f8\u7b26\u7684\u57ce\u5e02\uff1a\u8acb\u78ba\u8a8d\u57ce\u5e02\u6b04\u4f4d" + }, "step": { + "cities": { + "data": { + "city": "\u57ce\u5e02\u540d\u7a31" + }, + "description": "\u7531\u5217\u8868\u4e2d\u9078\u64c7\u57ce\u5e02", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "\u57ce\u5e02\u540d\u7a31" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "\u9810\u5831\u6a21\u5f0f" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 2983c6b7d59..a9c4840901b 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -1,88 +1,172 @@ """Support for Meteo-France weather service.""" -from datetime import timedelta import logging - -from meteofrance.client import meteofranceClient +import time from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import CONF_MODE, TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ATTRIBUTION, CONDITION_CLASSES, CONF_CITY, DOMAIN +from .const import ( + ATTRIBUTION, + CONDITION_CLASSES, + COORDINATOR_FORECAST, + DOMAIN, + FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, +) _LOGGER = logging.getLogger(__name__) +def format_condition(condition: str): + """Return condition from dict CONDITION_CLASSES.""" + for key, value in CONDITION_CLASSES.items(): + if condition in value: + return key + return condition + + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ) -> None: """Set up the Meteo-France weather platform.""" - city = entry.data[CONF_CITY] - client = hass.data[DOMAIN][city] + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR_FORECAST] - async_add_entities([MeteoFranceWeather(client)], True) + async_add_entities( + [ + MeteoFranceWeather( + coordinator, entry.options.get(CONF_MODE, FORECAST_MODE_DAILY), + ) + ], + True, + ) + _LOGGER.debug( + "Weather entity (%s) added for %s.", + entry.options.get(CONF_MODE, FORECAST_MODE_DAILY), + coordinator.data.position["name"], + ) class MeteoFranceWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, client: meteofranceClient): + def __init__(self, coordinator: DataUpdateCoordinator, mode: str): """Initialise the platform with a data instance and station name.""" - self._client = client - self._data = {} - - def update(self): - """Update current conditions.""" - self._client.update() - self._data = self._client.get_data() - - @property - def name(self): - """Return the name of the sensor.""" - return self._data["name"] + self.coordinator = coordinator + self._city_name = self.coordinator.data.position["name"] + self._mode = mode + self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}" @property def unique_id(self): """Return the unique id of the sensor.""" - return self.name + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._city_name @property def condition(self): """Return the current condition.""" - return self.format_condition(self._data["weather"]) + return format_condition( + self.coordinator.data.current_forecast["weather"]["desc"] + ) @property def temperature(self): """Return the temperature.""" - return self._data["temperature"] - - @property - def humidity(self): - """Return the humidity.""" - return None + return self.coordinator.data.current_forecast["T"]["value"] @property def temperature_unit(self): """Return the unit of measurement.""" return TEMP_CELSIUS + @property + def pressure(self): + """Return the pressure.""" + return self.coordinator.data.current_forecast["sea_level"] + + @property + def humidity(self): + """Return the humidity.""" + return self.coordinator.data.current_forecast["humidity"] + @property def wind_speed(self): """Return the wind speed.""" - return self._data["wind_speed"] + # convert from API m/s to km/h + return round(self.coordinator.data.current_forecast["wind"]["speed"] * 3.6) @property def wind_bearing(self): """Return the wind bearing.""" - return self._data["wind_bearing"] + wind_bearing = self.coordinator.data.current_forecast["wind"]["direction"] + if wind_bearing != -1: + return wind_bearing + + @property + def forecast(self): + """Return the forecast.""" + forecast_data = [] + + if self._mode == FORECAST_MODE_HOURLY: + today = time.time() + for forecast in self.coordinator.data.forecast: + # Can have data in the past + if forecast["dt"] < today: + _LOGGER.debug( + "remove forecast in the past: %s %s", self._mode, forecast + ) + continue + forecast_data.append( + { + ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( + forecast["dt"] + ), + ATTR_FORECAST_CONDITION: format_condition( + forecast["weather"]["desc"] + ), + ATTR_FORECAST_TEMP: forecast["T"]["value"], + ATTR_FORECAST_PRECIPITATION: forecast["rain"].get("1h"), + ATTR_FORECAST_WIND_SPEED: forecast["wind"]["speed"], + ATTR_FORECAST_WIND_BEARING: forecast["wind"]["direction"] + if forecast["wind"]["direction"] != -1 + else None, + } + ) + else: + for forecast in self.coordinator.data.daily_forecast: + # stop when we don't have a weather condition (can happen around last days of forcast, max 14) + if not forecast.get("weather12H"): + break + forecast_data.append( + { + ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( + forecast["dt"] + ), + ATTR_FORECAST_CONDITION: format_condition( + forecast["weather12H"]["desc"] + ), + ATTR_FORECAST_TEMP: forecast["T"]["max"], + ATTR_FORECAST_TEMP_LOW: forecast["T"]["min"], + ATTR_FORECAST_PRECIPITATION: forecast["precipitation"]["24h"], + } + ) + return forecast_data @property def attribution(self): @@ -90,36 +174,24 @@ class MeteoFranceWeather(WeatherEntity): return ATTRIBUTION @property - def forecast(self): - """Return the forecast.""" - reftime = dt_util.utcnow().replace(hour=12, minute=0, second=0, microsecond=0) - reftime += timedelta(hours=24) - _LOGGER.debug("reftime used for %s forecast: %s", self._data["name"], reftime) - forecast_data = [] - for key in self._data["forecast"]: - value = self._data["forecast"][key] - data_dict = { - ATTR_FORECAST_TIME: reftime.isoformat(), - ATTR_FORECAST_TEMP: int(value["max_temp"]), - ATTR_FORECAST_TEMP_LOW: int(value["min_temp"]), - ATTR_FORECAST_CONDITION: self.format_condition(value["weather"]), - } - reftime = reftime + timedelta(hours=24) - forecast_data.append(data_dict) - return forecast_data - - @staticmethod - def format_condition(condition): - """Return condition from dict CONDITION_CLASSES.""" - for key, value in CONDITION_CLASSES.items(): - if condition in value: - return key - return condition + def available(self): + """Return if state is available.""" + return self.coordinator.last_update_success @property - def device_state_attributes(self): - """Return the state attributes.""" - data = {} - if self._data and "next_rain" in self._data: - data["next_rain"] = self._data["next_rain"] - return data + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_update(self): + """Only used by the generic entity update service.""" + if not self.enabled: + return + + await self.coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) diff --git a/homeassistant/components/metoffice/translations/no.json b/homeassistant/components/metoffice/translations/no.json index 0711e25b73f..0dc9d305d97 100644 --- a/homeassistant/components/metoffice/translations/no.json +++ b/homeassistant/components/metoffice/translations/no.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 90150f448e8..0b4ea0c5ea3 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -390,7 +390,7 @@ def get_api(hass, entry): _LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST]) _login_method = (login_plain, login_token) - kwargs = {"login_methods": _login_method, "port": entry["port"]} + kwargs = {"login_methods": _login_method, "port": entry["port"], "encoding": "utf8"} if entry[CONF_VERIFY_SSL]: ssl_context = ssl.create_default_context() diff --git a/homeassistant/components/mikrotik/translations/no.json b/homeassistant/components/mikrotik/translations/no.json index 894b70cbbe5..1e528fa4986 100644 --- a/homeassistant/components/mikrotik/translations/no.json +++ b/homeassistant/components/mikrotik/translations/no.json @@ -14,7 +14,7 @@ "host": "Vert", "name": "Navn", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn", "verify_ssl": "Bruk ssl" }, diff --git a/homeassistant/components/mill/translations/no.json b/homeassistant/components/mill/translations/no.json new file mode 100644 index 00000000000..0f698cbbddb --- /dev/null +++ b/homeassistant/components/mill/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "connection_error": "Tilkobling mislyktes." + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 6c99a8db60c..a5e31829d97 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -1,4 +1,4 @@ -"""Support for displaying the minimal and the maximal value.""" +"""Support for displaying minimal, maximal, mean or median values.""" import logging import voluptuous as vol @@ -24,6 +24,7 @@ ATTR_MAX_VALUE = "max_value" ATTR_MAX_ENTITY_ID = "max_entity_id" ATTR_COUNT_SENSORS = "count_sensors" ATTR_MEAN = "mean" +ATTR_MEDIAN = "median" ATTR_LAST = "last" ATTR_LAST_ENTITY_ID = "last_entity_id" @@ -32,6 +33,7 @@ ATTR_TO_PROPERTY = [ ATTR_MAX_VALUE, ATTR_MAX_ENTITY_ID, ATTR_MEAN, + ATTR_MEDIAN, ATTR_MIN_VALUE, ATTR_MIN_ENTITY_ID, ATTR_LAST, @@ -47,6 +49,7 @@ SENSOR_TYPES = { ATTR_MIN_VALUE: "min", ATTR_MAX_VALUE: "max", ATTR_MEAN: "mean", + ATTR_MEDIAN: "median", ATTR_LAST: "last", } @@ -80,7 +83,7 @@ def calc_min(sensor_values): val = None entity_id = None for sensor_id, sensor_value in sensor_values: - if sensor_value != STATE_UNKNOWN: + if sensor_value not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: if val is None or val > sensor_value: entity_id, val = sensor_id, sensor_value return entity_id, val @@ -91,7 +94,7 @@ def calc_max(sensor_values): val = None entity_id = None for sensor_id, sensor_value in sensor_values: - if sensor_value != STATE_UNKNOWN: + if sensor_value not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: if val is None or val < sensor_value: entity_id, val = sensor_id, sensor_value return entity_id, val @@ -99,15 +102,31 @@ def calc_max(sensor_values): def calc_mean(sensor_values, round_digits): """Calculate mean value, honoring unknown states.""" - sensor_value_sum = 0 - count = 0 + result = [] for _, sensor_value in sensor_values: - if sensor_value != STATE_UNKNOWN: - sensor_value_sum += sensor_value - count += 1 - if count == 0: + if sensor_value not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + result.append(sensor_value) + if len(result) == 0: return None - return round(sensor_value_sum / count, round_digits) + return round(sum(result) / len(result), round_digits) + + +def calc_median(sensor_values, round_digits): + """Calculate median value, honoring unknown states.""" + result = [] + for _, sensor_value in sensor_values: + if sensor_value not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + result.append(sensor_value) + if len(result) == 0: + return None + result.sort() + if len(result) % 2 == 0: + median1 = result[len(result) // 2] + median2 = result[len(result) // 2 - 1] + median = (median1 + median2) / 2 + else: + median = result[len(result) // 2] + return round(median, round_digits) class MinMaxSensor(Entity): @@ -126,7 +145,7 @@ class MinMaxSensor(Entity): self._name = f"{next(v for k, v in SENSOR_TYPES.items() if self._sensor_type == v)} sensor".capitalize() self._unit_of_measurement = None self._unit_of_measurement_mismatch = False - self.min_value = self.max_value = self.mean = self.last = None + self.min_value = self.max_value = self.mean = self.last = self.median = None self.min_entity_id = self.max_entity_id = self.last_entity_id = None self.count_sensors = len(self._entity_ids) self.states = {} @@ -224,3 +243,4 @@ class MinMaxSensor(Entity): self.min_entity_id, self.min_value = calc_min(sensor_values) self.max_entity_id, self.max_value = calc_max(sensor_values) self.mean = calc_mean(sensor_values, self._round_digits) + self.median = calc_median(sensor_values, self._round_digits) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index ca9c31011ed..d03505b0cb9 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -538,3 +538,15 @@ async def webhook_get_config(hass, config_entry, data): pass return webhook_response(resp, registration=config_entry.data) + + +@WEBHOOK_COMMANDS.register("scan_tag") +@validate_schema({vol.Required("tag_id"): cv.string}) +async def webhook_scan_tag(hass, config_entry, data): + """Handle a fire event webhook.""" + hass.bus.async_fire( + "tag_scanned", + {"tag_id": data["tag_id"], "device_id": config_entry.data[ATTR_DEVICE_ID]}, + context=registration_context(config_entry.data), + ) + return empty_okay_response() diff --git a/homeassistant/components/monoprice/translations/es.json b/homeassistant/components/monoprice/translations/es.json index fabc33234b6..549b7134234 100644 --- a/homeassistant/components/monoprice/translations/es.json +++ b/homeassistant/components/monoprice/translations/es.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "port": "Puerto serie", + "port": "Puerto", "source_1": "Nombre de la fuente #1", "source_2": "Nombre de la fuente #2", "source_3": "Nombre de la fuente #3", diff --git a/homeassistant/components/monoprice/translations/no.json b/homeassistant/components/monoprice/translations/no.json index 3de551f073a..acd4bde8774 100644 --- a/homeassistant/components/monoprice/translations/no.json +++ b/homeassistant/components/monoprice/translations/no.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "port": "Port", + "port": "", "source_1": "Navn p\u00e5 kilden #1", "source_2": "Navn p\u00e5 kilden #2", "source_3": "Navn p\u00e5 kilden #3", diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a0527cfe427..81c44ac8aea 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -44,6 +44,7 @@ from . import config_flow # noqa: F401 pylint: disable=unused-import from . import debug_info, discovery from .const import ( ATTR_DISCOVERY_HASH, + ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, ATTR_PAYLOAD, ATTR_QOS, @@ -1169,6 +1170,7 @@ class MqttDiscoveryUpdate(Entity): _LOGGER.info( "Got update for entity with hash: %s '%s'", discovery_hash, payload, ) + old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) if not payload: # Empty payload: Remove component @@ -1176,9 +1178,13 @@ class MqttDiscoveryUpdate(Entity): self._cleanup_discovery_on_remove() await _async_remove_state_and_registry_entry(self) elif self._discovery_update: - # Non-empty payload: Notify component - _LOGGER.info("Updating component: %s", self.entity_id) - await self._discovery_update(payload) + if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: + # Non-empty, changed payload: Notify component + _LOGGER.info("Updating component: %s", self.entity_id) + await self._discovery_update(payload) + else: + # Non-empty, unchanged payload: Ignore to avoid changing states + _LOGGER.info("Ignoring unchanged update for: %s", self.entity_id) if discovery_hash: debug_info.add_entity_discovery_data( diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index cd69967e6a7..5d69bfde4f6 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -169,7 +169,8 @@ class MqttBinarySensor( if expire_after is not None and expire_after > 0: - # When expire_after is set, and we receive a message, assume device is not expired since it has to be to receive the message + # When expire_after is set, and we receive a message, assume device is + # not expired since it has to be to receive the message self._expired = False # Reset old trigger diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index a9f18e7039b..d14cda70bb6 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -101,10 +101,10 @@ async def async_setup_entity_template( config, async_add_entities, config_entry, discovery_data ): """Set up a MQTT Template light.""" - async_add_entities([MqttTemplate(config, config_entry, discovery_data)]) + async_add_entities([MqttLightTemplate(config, config_entry, discovery_data)]) -class MqttTemplate( +class MqttLightTemplate( MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index d10bc8bc4e6..75c3fdec260 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -63,10 +63,12 @@ "description": "Please select MQTT options.", "data": { "discovery": "Enable discovery", + "birth_enable": "Enable birth message", "birth_topic": "Birth message topic", "birth_payload": "Birth message payload", "birth_qos": "Birth message QoS", "birth_retain": "Birth message retain", + "will_enable": "Enable birth message", "will_topic": "Will message topic", "will_payload": "Will message payload", "will_qos": "Will message QoS", diff --git a/homeassistant/components/mqtt/translations/cs.json b/homeassistant/components/mqtt/translations/cs.json index 95bd6d68c8f..87ab2de60d3 100644 --- a/homeassistant/components/mqtt/translations/cs.json +++ b/homeassistant/components/mqtt/translations/cs.json @@ -41,6 +41,7 @@ }, "options": { "data": { + "birth_enable": "Povolit zpr\u00e1vu p\u0159i p\u0159ipojen\u00ed", "discovery": "Povolit zji\u0161\u0165ov\u00e1n\u00ed" }, "description": "Zvolte mo\u017enosti MQTT." diff --git a/homeassistant/components/mqtt/translations/en.json b/homeassistant/components/mqtt/translations/en.json index 99cd59be13b..8ece91cb85d 100644 --- a/homeassistant/components/mqtt/translations/en.json +++ b/homeassistant/components/mqtt/translations/en.json @@ -66,11 +66,13 @@ }, "options": { "data": { + "birth_enable": "Enable birth message", "birth_payload": "Birth message payload", "birth_qos": "Birth message QoS", "birth_retain": "Birth message retain", "birth_topic": "Birth message topic", "discovery": "Enable discovery", + "will_enable": "Enable birth message", "will_payload": "Will message payload", "will_qos": "Will message QoS", "will_retain": "Will message retain", diff --git a/homeassistant/components/mqtt/translations/es.json b/homeassistant/components/mqtt/translations/es.json index 52dda70695a..4ebf1e11c77 100644 --- a/homeassistant/components/mqtt/translations/es.json +++ b/homeassistant/components/mqtt/translations/es.json @@ -66,11 +66,13 @@ }, "options": { "data": { + "birth_enable": "Habilitar mensaje de nacimiento", "birth_payload": "Carga del mensaje de nacimiento", "birth_qos": "QoS del mensaje de nacimiento", "birth_retain": "Retenci\u00f3n del mensaje de nacimiento", "birth_topic": "Tema del mensaje de nacimiento", "discovery": "Habilitar descubrimiento", + "will_enable": "Habilitar mensaje de nacimiento", "will_payload": "Enviar\u00e1 la carga", "will_qos": "El mensaje usar\u00e1 el QoS", "will_retain": "Retendr\u00e1 el mensaje", diff --git a/homeassistant/components/mqtt/translations/no.json b/homeassistant/components/mqtt/translations/no.json index e48f70d5bd5..b1863b90d1c 100644 --- a/homeassistant/components/mqtt/translations/no.json +++ b/homeassistant/components/mqtt/translations/no.json @@ -12,7 +12,7 @@ "broker": "Megler", "discovery": "Aktiver oppdagelse", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn" }, "description": "Vennligst fyll ut tilkoblingsinformasjonen for din MQTT megler." @@ -58,6 +58,7 @@ "broker": { "data": { "broker": "Megler", + "password": "Passord", "port": "", "username": "Brukernavn" }, @@ -65,11 +66,13 @@ }, "options": { "data": { + "birth_enable": "Aktiver f\u00f8dselsmelding", "birth_payload": "F\u00f8dselsmelding nyttelast", "birth_qos": "F\u00f8dselsmelding QoS", "birth_retain": "F\u00f8dselsmelding behold", "birth_topic": "F\u00f8dselsmelding emne", "discovery": "Aktiver oppdagelse", + "will_enable": "Aktiver f\u00f8dselsmelding", "will_payload": "Testament melding nyttelast", "will_qos": "Testament melding QoS", "will_retain": "Testament melding behold", diff --git a/homeassistant/components/mqtt/translations/pl.json b/homeassistant/components/mqtt/translations/pl.json index f5fe53d5fce..8c90db3e773 100644 --- a/homeassistant/components/mqtt/translations/pl.json +++ b/homeassistant/components/mqtt/translations/pl.json @@ -58,9 +58,9 @@ "broker": { "data": { "broker": "Po\u015brednik", - "password": "Has\u0142o", + "password": "[%key_id:common::config_flow::data::password%]", "port": "Port", - "username": "U\u017cytkownik" + "username": "[%key_id:common::config_flow::data::username%]" }, "description": "Wprowad\u017a informacje o po\u0142\u0105czeniu po\u015brednika MQTT" }, diff --git a/homeassistant/components/mqtt/translations/ru.json b/homeassistant/components/mqtt/translations/ru.json index 8139781f51e..4ff21126cfa 100644 --- a/homeassistant/components/mqtt/translations/ru.json +++ b/homeassistant/components/mqtt/translations/ru.json @@ -66,11 +66,13 @@ }, "options": { "data": { + "birth_enable": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u0442\u043e\u043f\u0438\u043a \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "birth_payload": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0442\u043e\u043f\u0438\u043a\u0430 \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "birth_qos": "QoS \u0442\u043e\u043f\u0438\u043a\u0430 \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "birth_retain": "\u0421\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c \u0442\u043e\u043f\u0438\u043a \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "birth_topic": "\u0422\u043e\u043f\u0438\u043a \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 (LWT)", "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435", + "will_enable": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u0442\u043e\u043f\u0438\u043a \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "will_payload": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0442\u043e\u043f\u0438\u043a\u0430 \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "will_qos": "QoS \u0442\u043e\u043f\u0438\u043a\u0430 \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "will_retain": "\u0421\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c \u0442\u043e\u043f\u0438\u043a \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index a80d555708c..311f5ff5f42 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -108,7 +108,7 @@ async def async_setup_entry(hass, entry): hass.data[NEATO_LOGIN] = hub for component in ("camera", "vacuum", "switch", "sensor"): - hass.async_add_job( + hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index cb5408c2259..0995511abcc 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -15,13 +15,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_DISCOVERY, - CONF_USERNAME, CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from . import api, config_flow @@ -29,30 +27,25 @@ from .const import ( AUTH, CONF_CLOUDHOOK_URL, DATA_DEVICE_IDS, + DATA_HANDLER, + DATA_HOMES, DATA_PERSONS, + DATA_SCHEDULES, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) +from .data_handler import NetatmoDataHandler from .webhook import handle_webhook _LOGGER = logging.getLogger(__name__) -CONF_SECRET_KEY = "secret_key" -CONF_WEBHOOKS = "webhooks" - -WAIT_FOR_CLOUD = 5 - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, - cv.deprecated(CONF_SECRET_KEY): cv.match_all, - cv.deprecated(CONF_USERNAME): cv.match_all, - cv.deprecated(CONF_WEBHOOKS): cv.match_all, - cv.deprecated(CONF_DISCOVERY): cv.match_all, } ) }, @@ -67,6 +60,8 @@ async def async_setup(hass: HomeAssistant, config: dict): hass.data[DOMAIN] = {} hass.data[DOMAIN][DATA_PERSONS] = {} hass.data[DOMAIN][DATA_DEVICE_IDS] = {} + hass.data[DOMAIN][DATA_SCHEDULES] = {} + hass.data[DOMAIN][DATA_HOMES] = {} if DOMAIN not in config: return True @@ -100,27 +95,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): AUTH: api.ConfigEntryNetatmoAuth(hass, entry, implementation) } + data_handler = NetatmoDataHandler(hass, entry) + await data_handler.async_setup() + hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] = data_handler + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) - async def unregister_webhook(event): + async def unregister_webhook(_): + if CONF_WEBHOOK_ID not in entry.data: + return _LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) async def register_webhook(event): - # Wait for the cloud integration to be ready - await asyncio.sleep(WAIT_FOR_CLOUD) - if CONF_WEBHOOK_ID not in entry.data: data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} hass.config_entries.async_update_entry(entry, data=data) if hass.components.cloud.async_active_subscription(): - # Wait for cloud connection to be established - await asyncio.sleep(WAIT_FOR_CLOUD) - if CONF_CLOUDHOOK_URL not in entry.data: webhook_url = await hass.components.cloud.async_create_cloudhook( entry.data[CONF_WEBHOOK_ID] @@ -134,20 +129,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): entry.data[CONF_WEBHOOK_ID] ) - try: - await hass.async_add_executor_job( - hass.data[DOMAIN][entry.entry_id][AUTH].addwebhook, webhook_url + if entry.data["auth_implementation"] == "cloud" and not webhook_url.startswith( + "https://" + ): + _LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" ) + return + + try: webhook_register( hass, DOMAIN, "Netatmo", entry.data[CONF_WEBHOOK_ID], handle_webhook ) + await hass.async_add_executor_job( + hass.data[DOMAIN][entry.entry_id][AUTH].addwebhook, webhook_url + ) _LOGGER.info("Register Netatmo webhook: %s", webhook_url) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) except pyatmo.ApiError as err: _LOGGER.error("Error during webhook registration - %s", err) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, register_webhook) + if hass.state == CoreState.running: + await register_webhook(None) + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, register_webhook) + + hass.services.async_register(DOMAIN, "register_webhook", register_webhook) + hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook) + return True @@ -157,6 +171,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): await hass.async_add_executor_job( hass.data[DOMAIN][entry.entry_id][AUTH].dropwebhook ) + _LOGGER.info("Unregister Netatmo webhook.") + + await hass.data[DOMAIN][entry.entry_id][DATA_HANDLER].async_cleanup() unload_ok = all( await asyncio.gather( @@ -175,7 +192,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): """Cleanup when entry is removed.""" - if CONF_WEBHOOK_ID in entry.data: + if ( + CONF_WEBHOOK_ID in entry.data + and hass.components.cloud.async_active_subscription() + ): try: _LOGGER.debug( "Removing Netatmo cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] diff --git a/homeassistant/components/netatmo/api.py b/homeassistant/components/netatmo/api.py index 9a34888fd72..b8b259ed5c1 100644 --- a/homeassistant/components/netatmo/api.py +++ b/homeassistant/components/netatmo/api.py @@ -10,7 +10,7 @@ from homeassistant.helpers import config_entry_oauth2_flow _LOGGER = logging.getLogger(__name__) -class ConfigEntryNetatmoAuth(pyatmo.auth.NetatmOAuth2): +class ConfigEntryNetatmoAuth(pyatmo.auth.NetatmoOAuth2): """Provide Netatmo authentication tied to an OAuth2 based config entry.""" def __init__( diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 30f209625f6..8fbff3225dd 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -10,59 +10,113 @@ from homeassistant.components.camera import ( SUPPORT_STREAM, Camera, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, entity_platform from .const import ( + ATTR_PERSON, + ATTR_PERSONS, ATTR_PSEUDO, - AUTH, + DATA_HANDLER, DATA_PERSONS, DOMAIN, MANUFACTURER, - MIN_TIME_BETWEEN_EVENT_UPDATES, - MIN_TIME_BETWEEN_UPDATES, MODELS, + SERVICE_SETPERSONAWAY, + SERVICE_SETPERSONSHOME, + SIGNAL_NAME, ) +from .data_handler import CAMERA_DATA_CLASS_NAME +from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) -CONF_HOME = "home" -CONF_CAMERAS = "cameras" -CONF_QUALITY = "quality" - DEFAULT_QUALITY = "high" -VALID_QUALITIES = ["high", "medium", "low", "poor"] +SCHEMA_SERVICE_SETPERSONSHOME = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_domain(CAMERA_DOMAIN), + vol.Required(ATTR_PERSONS): vol.All(cv.ensure_list, [cv.string]), + } +) -_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} - -SCHEMA_SERVICE_SETLIGHTAUTO = vol.Schema( - {vol.Optional(ATTR_ENTITY_ID): cv.entity_domain(CAMERA_DOMAIN)} +SCHEMA_SERVICE_SETPERSONAWAY = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_domain(CAMERA_DOMAIN), + vol.Optional(ATTR_PERSON): cv.string, + } ) async def async_setup_entry(hass, entry, async_add_entities): """Set up the Netatmo camera platform.""" + if "access_camera" not in entry.data["token"]["scope"]: + _LOGGER.info( + "Cameras are currently not supported with this authentication method" + ) + return - def get_entities(): + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] + + async def get_entities(): """Retrieve Netatmo entities.""" + await data_handler.register_data_class( + CAMERA_DATA_CLASS_NAME, CAMERA_DATA_CLASS_NAME, None + ) + + data = data_handler.data + + if not data.get(CAMERA_DATA_CLASS_NAME): + return [] + + data_class = data_handler.data[CAMERA_DATA_CLASS_NAME] + entities = [] try: - camera_data = CameraData(hass, hass.data[DOMAIN][entry.entry_id][AUTH]) - for camera in camera_data.get_all_cameras(): - _LOGGER.debug("Setting up camera %s %s", camera["id"], camera["name"]) + all_cameras = [] + for home in data_class.cameras.values(): + for camera in home.values(): + all_cameras.append(camera) + + for camera in all_cameras: + _LOGGER.debug("Adding camera %s %s", camera["id"], camera["name"]) entities.append( NetatmoCamera( - camera_data, camera["id"], camera["type"], True, DEFAULT_QUALITY + data_handler, + camera["id"], + camera["type"], + camera["home_id"], + DEFAULT_QUALITY, ) ) - camera_data.update_persons() + + for person_id, person_data in data_handler.data[ + CAMERA_DATA_CLASS_NAME + ].persons.items(): + hass.data[DOMAIN][DATA_PERSONS][person_id] = person_data.get( + ATTR_PSEUDO + ) except pyatmo.NoDevice: _LOGGER.debug("No cameras found") + return entities - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(await get_entities(), True) + + platform = entity_platform.current_platform.get() + + if data_handler.data[CAMERA_DATA_CLASS_NAME] is not None: + platform.async_register_entity_service( + SERVICE_SETPERSONSHOME, + SCHEMA_SERVICE_SETPERSONSHOME, + "_service_setpersonshome", + ) + platform.async_register_entity_service( + SERVICE_SETPERSONAWAY, + SCHEMA_SERVICE_SETPERSONAWAY, + "_service_setpersonaway", + ) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -70,19 +124,26 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= return -class NetatmoCamera(Camera): +class NetatmoCamera(NetatmoBase, Camera): """Representation of a Netatmo camera.""" - def __init__(self, data, camera_id, camera_type, verify_ssl, quality): + def __init__( + self, data_handler, camera_id, camera_type, home_id, quality, + ): """Set up for access to the Netatmo camera images.""" - super().__init__() - self._data = data - self._camera_id = camera_id - self._camera_name = self._data.camera_data.get_camera(cid=camera_id).get("name") - self._name = f"{MANUFACTURER} {self._camera_name}" - self._camera_type = camera_type - self._unique_id = f"{self._camera_id}-{self._camera_type}" - self._verify_ssl = verify_ssl + Camera.__init__(self) + super().__init__(data_handler) + + self._data_classes.append( + {"name": CAMERA_DATA_CLASS_NAME, SIGNAL_NAME: CAMERA_DATA_CLASS_NAME} + ) + + self._id = camera_id + self._home_id = home_id + self._device_name = self._data.get_camera(camera_id=camera_id).get("name") + self._name = f"{MANUFACTURER} {self._device_name}" + self._model = camera_type + self._unique_id = f"{self._id}-{self._model}" self._quality = quality self._vpnurl = None self._localurl = None @@ -91,6 +152,35 @@ class NetatmoCamera(Camera): self._alim_status = None self._is_local = None + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + + self._listeners.append( + self.hass.bus.async_listen("netatmo_event", self.handle_event) + ) + + async def handle_event(self, event): + """Handle webhook events.""" + data = event.data["data"] + + if not data.get("event_type"): + return + + if not data.get("camera_id"): + return + + if data["home_id"] == self._home_id and data["camera_id"] == self._id: + if data["push_type"] in ["NACamera-off", "NACamera-disconnection"]: + self.is_streaming = False + self._status = "off" + elif data["push_type"] in ["NACamera-on", "NACamera-connection"]: + self.is_streaming = True + self._status = "on" + + self.async_write_ha_state() + return + def camera_image(self): """Return a still image response from the camera.""" try: @@ -100,77 +190,46 @@ class NetatmoCamera(Camera): ) elif self._vpnurl: response = requests.get( - f"{self._vpnurl}/live/snapshot_720.jpg", - timeout=10, - verify=self._verify_ssl, + f"{self._vpnurl}/live/snapshot_720.jpg", timeout=10, verify=True, ) else: _LOGGER.error("Welcome/Presence VPN URL is None") - self._data.update() - (self._vpnurl, self._localurl) = self._data.camera_data.camera_urls( - cid=self._camera_id + (self._vpnurl, self._localurl) = self._data.camera_urls( + camera_id=self._id ) return None + except requests.exceptions.RequestException as error: _LOGGER.info("Welcome/Presence URL changed: %s", error) - self._data.update() - (self._vpnurl, self._localurl) = self._data.camera_data.camera_urls( - cid=self._camera_id - ) + self._data.update_camera_urls(camera_id=self._id) + (self._vpnurl, self._localurl) = self._data.camera_urls(camera_id=self._id) return None + return response.content - @property - def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ - return True - - @property - def name(self): - """Return the name of this Netatmo camera device.""" - return self._name - - @property - def device_info(self): - """Return the device info for the sensor.""" - return { - "identifiers": {(DOMAIN, self._camera_id)}, - "name": self._camera_name, - "manufacturer": MANUFACTURER, - "model": MODELS[self._camera_type], - } - @property def device_state_attributes(self): """Return the Netatmo-specific camera state attributes.""" - attr = {} - attr["id"] = self._camera_id - attr["status"] = self._status - attr["sd_status"] = self._sd_status - attr["alim_status"] = self._alim_status - attr["is_local"] = self._is_local - attr["vpn_url"] = self._vpnurl - - return attr + return { + "id": self._id, + "status": self._status, + "sd_status": self._sd_status, + "alim_status": self._alim_status, + "is_local": self._is_local, + "vpn_url": self._vpnurl, + "local_url": self._localurl, + } @property def available(self): """Return True if entity is available.""" - return bool(self._alim_status == "on") + return bool(self._alim_status == "on" or self._status == "disconnected") @property def supported_features(self): """Return supported features.""" return SUPPORT_STREAM - @property - def is_recording(self): - """Return true if the device is recording.""" - return bool(self._status == "on") - @property def brand(self): """Return the camera brand.""" @@ -186,6 +245,16 @@ class NetatmoCamera(Camera): """Return true if on.""" return self.is_streaming + def turn_off(self): + """Turn off camera.""" + self._data.set_state( + home_id=self._home_id, camera_id=self._id, monitoring="off" + ) + + def turn_on(self): + """Turn on camera.""" + self._data.set_state(home_id=self._home_id, camera_id=self._id, monitoring="on") + async def stream_source(self): """Return the stream source.""" url = "{0}/live/files/{1}/index.m3u8" @@ -196,72 +265,48 @@ class NetatmoCamera(Camera): @property def model(self): """Return the camera model.""" - if self._camera_type == "NOC": - return "Presence" - if self._camera_type == "NACamera": - return "Welcome" - return None + return MODELS[self._model] - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - - def update(self): - """Update entity status.""" - self._data.update() - - camera = self._data.camera_data.get_camera(cid=self._camera_id) - - self._vpnurl, self._localurl = self._data.camera_data.camera_urls( - cid=self._camera_id - ) + @callback + def async_update_callback(self): + """Update the entity's state.""" + camera = self._data.get_camera(self._id) + self._vpnurl, self._localurl = self._data.camera_urls(self._id) self._status = camera.get("status") self._sd_status = camera.get("sd_status") self._alim_status = camera.get("alim_status") self._is_local = camera.get("is_local") - self.is_streaming = self._alim_status == "on" + self.is_streaming = bool(self._status == "on") + def _service_setpersonshome(self, **kwargs): + """Service to change current home schedule.""" + persons = kwargs.get(ATTR_PERSONS) + person_ids = [] + for person in persons: + for pid, data in self._data.persons.items(): + if data.get("pseudo") == person: + person_ids.append(pid) -class CameraData: - """Get the latest data from Netatmo.""" + self._data.set_persons_home(person_ids=person_ids, home_id=self._home_id) + _LOGGER.info("Set %s as at home", persons) - def __init__(self, hass, auth): - """Initialize the data object.""" - self._hass = hass - self.auth = auth - self.camera_data = None + def _service_setpersonaway(self, **kwargs): + """Service to mark a person as away or set the home as empty.""" + person = kwargs.get(ATTR_PERSON) + person_id = None + if person: + for pid, data in self._data.persons.items(): + if data.get("pseudo") == person: + person_id = pid - def get_all_cameras(self): - """Return all camera available on the API as a list.""" - self.update() - cameras = [] - for camera in self.camera_data.cameras.values(): - cameras.extend(camera.values()) - return cameras - - def get_modules(self, camera_id): - """Return all modules for a given camera.""" - return self.camera_data.get_camera(camera_id).get("modules", []) - - def get_camera_type(self, camera_id): - """Return camera type for a camera, cid has preference over camera.""" - return self.camera_data.cameraType(cid=camera_id) - - def update_persons(self): - """Gather person data for webhooks.""" - for person_id, person_data in self.camera_data.persons.items(): - self._hass.data[DOMAIN][DATA_PERSONS][person_id] = person_data.get( - ATTR_PSEUDO + if person_id is not None: + self._data.set_persons_away( + person_id=person_id, home_id=self._home_id, ) + _LOGGER.info("Set %s as away", person) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Call the Netatmo API to update the data.""" - self.camera_data = pyatmo.CameraData(self.auth, size=100) - self.update_persons() - - @Throttle(MIN_TIME_BETWEEN_EVENT_UPDATES) - def update_event(self, camera_type): - """Call the Netatmo API to update the events.""" - self.camera_data.updateEvent(devicetype=camera_type) + else: + self._data.set_persons_away( + person_id=person_id, home_id=self._home_id, + ) + _LOGGER.info("Set home as empty") diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 8de2694095e..459f005695b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,13 +1,10 @@ """Support for Netatmo Smart thermostats.""" -from datetime import timedelta import logging from typing import List, Optional -import pyatmo -import requests import voluptuous as vol -from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, @@ -22,23 +19,28 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ( ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, ATTR_TEMPERATURE, PRECISION_HALVES, STATE_OFF, TEMP_CELSIUS, ) -from homeassistant.helpers import config_validation as cv -from homeassistant.util import Throttle +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, entity_platform from .const import ( - ATTR_HOME_NAME, + ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, - AUTH, + DATA_HANDLER, + DATA_HOMES, + DATA_SCHEDULES, DOMAIN, MANUFACTURER, - MODELS, SERVICE_SETSCHEDULE, + SIGNAL_NAME, ) +from .data_handler import HOMEDATA_DATA_CLASS_NAME, HOMESTATUS_DATA_CLASS_NAME +from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -88,11 +90,6 @@ HVAC_MAP_NETATMO = { CURRENT_HVAC_MAP_NETATMO = {True: CURRENT_HVAC_HEAT, False: CURRENT_HVAC_IDLE} -CONF_HOMES = "homes" -CONF_ROOMS = "rooms" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) - DEFAULT_MAX_TEMP = 30 NA_THERM = "NATherm1" @@ -100,54 +97,66 @@ NA_VALVE = "NRV" SCHEMA_SERVICE_SETSCHEDULE = vol.Schema( { + vol.Required(ATTR_ENTITY_ID): cv.entity_domain(CLIMATE_DOMAIN), vol.Required(ATTR_SCHEDULE_NAME): cv.string, - vol.Required(ATTR_HOME_NAME): cv.string, } ) async def async_setup_entry(hass, entry, async_add_entities): """Set up the Netatmo energy platform.""" - auth = hass.data[DOMAIN][entry.entry_id][AUTH] + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - home_data = HomeData(auth) + await data_handler.register_data_class( + HOMEDATA_DATA_CLASS_NAME, HOMEDATA_DATA_CLASS_NAME, None + ) + home_data = data_handler.data.get(HOMEDATA_DATA_CLASS_NAME) - def get_entities(): + if not home_data: + return + + async def get_entities(): """Retrieve Netatmo entities.""" entities = [] - try: - home_data.setup() - except pyatmo.NoDevice: - return - home_ids = home_data.get_all_home_ids() - for home_id in home_ids: + for home_id in get_all_home_ids(home_data): _LOGGER.debug("Setting up home %s ...", home_id) - try: - room_data = ThermostatData(auth, home_id) - except pyatmo.NoDevice: - continue - for room_id in room_data.get_room_ids(): - room_name = room_data.homedata.rooms[home_id][room_id]["name"] + for room_id in home_data.rooms[home_id].keys(): + room_name = home_data.rooms[home_id][room_id]["name"] _LOGGER.debug("Setting up room %s (%s) ...", room_name, room_id) - entities.append(NetatmoThermostat(room_data, room_id)) + signal_name = f"{HOMESTATUS_DATA_CLASS_NAME}-{home_id}" + await data_handler.register_data_class( + HOMESTATUS_DATA_CLASS_NAME, signal_name, None, home_id=home_id + ) + home_status = data_handler.data.get(signal_name) + 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_HOMES] = { + home_id: home_data.get("name") + for home_id, home_data in ( + data_handler.data[HOMEDATA_DATA_CLASS_NAME].homes.items() + ) + } + return entities - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(await get_entities(), True) - def _service_setschedule(service): - """Service to change current home schedule.""" - home_name = service.data.get(ATTR_HOME_NAME) - schedule_name = service.data.get(ATTR_SCHEDULE_NAME) - home_data.homedata.switchHomeSchedule(schedule=schedule_name, home=home_name) - _LOGGER.info("Set home (%s) schedule to %s", home_name, schedule_name) + platform = entity_platform.current_platform.get() - if home_data.homedata is not None: - hass.services.async_register( - DOMAIN, - SERVICE_SETSCHEDULE, - _service_setschedule, - schema=SCHEMA_SERVICE_SETSCHEDULE, + if home_data is not None: + platform.async_register_entity_service( + SERVICE_SETSCHEDULE, SCHEMA_SERVICE_SETSCHEDULE, "_service_setschedule", ) @@ -156,16 +165,46 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= return -class NetatmoThermostat(ClimateEntity): +class NetatmoThermostat(NetatmoBase, ClimateEntity): """Representation a Netatmo thermostat.""" - def __init__(self, data, room_id): + def __init__(self, data_handler, home_id, room_id): """Initialize the sensor.""" - self._data = data + ClimateEntity.__init__(self) + super().__init__(data_handler) + + self._id = room_id + self._home_id = home_id + + self._home_status_class = f"{HOMESTATUS_DATA_CLASS_NAME}-{self._home_id}" + + self._data_classes.extend( + [ + { + "name": HOMEDATA_DATA_CLASS_NAME, + SIGNAL_NAME: HOMEDATA_DATA_CLASS_NAME, + }, + { + "name": HOMESTATUS_DATA_CLASS_NAME, + "home_id": self._home_id, + SIGNAL_NAME: self._home_status_class, + }, + ] + ) + + self._home_status = self.data_handler.data[self._home_status_class] + self._room_status = self._home_status.rooms[room_id] + self._room_data = self._data.rooms[home_id][room_id] + + self._model = NA_VALVE + for module in self._room_data.get("module_ids"): + if self._home_status.thermostats.get(module): + self._model = NA_THERM + break + self._state = None - self._room_id = room_id - self._room_name = self._data.homedata.rooms[self._data.home_id][room_id]["name"] - self._name = f"{MANUFACTURER} {self._room_name}" + self._device_name = self._data.rooms[home_id][room_id]["name"] + self._name = f"{MANUFACTURER} {self._device_name}" self._current_temperature = None self._target_temperature = None self._preset = None @@ -175,41 +214,72 @@ class NetatmoThermostat(ClimateEntity): self._hvac_mode = None self._battery_level = None self._connected = None - self.update_without_throttle = False - self._module_type = self._data.room_status.get(room_id, {}).get( - "module_type", NA_VALVE - ) - if self._module_type == NA_THERM: + self._away_temperature = None + self._hg_temperature = None + self._boilerstatus = None + self._setpoint_duration = None + + if self._model == NA_THERM: self._operation_list.append(HVAC_MODE_OFF) - self._unique_id = f"{self._room_id}-{self._module_type}" + self._unique_id = f"{self._id}-{self._model}" - @property - def device_info(self): - """Return the device info for the thermostat/valve.""" - return { - "identifiers": {(DOMAIN, self._room_id)}, - "name": self._room_name, - "manufacturer": MANUFACTURER, - "model": MODELS[self._module_type], - } + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id + self._listeners.append( + self.hass.bus.async_listen("netatmo_event", self.handle_event) + ) + + async def handle_event(self, event): + """Handle webhook events.""" + data = event.data["data"] + + if not data.get("event_type"): + return + + if not data.get("home"): + return + + home = data["home"] + if self._home_id == home["id"] and data["event_type"] == "therm_mode": + self._preset = NETATMO_MAP_PRESET[home["therm_mode"]] + self._hvac_mode = HVAC_MAP_NETATMO[self._preset] + if self._preset == PRESET_FROST_GUARD: + self._target_temperature = self._hg_temperature + elif self._preset == PRESET_AWAY: + self._target_temperature = self._away_temperature + elif self._preset == PRESET_SCHEDULE: + self.async_update_callback() + self.async_write_ha_state() + return + + if not home.get("rooms"): + return + + for room in home["rooms"]: + if data["event_type"] == "set_point": + if self._id == room["id"]: + if room["therm_setpoint_mode"] == "off": + self._hvac_mode = HVAC_MODE_OFF + else: + self._target_temperature = room["therm_setpoint_temperature"] + self.async_write_ha_state() + break + + elif data["event_type"] == "cancel_set_point": + if self._id == room["id"]: + self.async_update_callback() + self.async_write_ha_state() + break @property def supported_features(self): """Return the list of supported features.""" return self._support_flags - @property - def name(self): - """Return the name of the thermostat.""" - return self._name - @property def temperature_unit(self): """Return the unit of measurement.""" @@ -243,15 +313,11 @@ class NetatmoThermostat(ClimateEntity): @property def hvac_action(self) -> Optional[str]: """Return the current running hvac operation if supported.""" - if self._module_type == NA_THERM: - return CURRENT_HVAC_MAP_NETATMO[self._data.boilerstatus] + if self._model == NA_THERM: + return CURRENT_HVAC_MAP_NETATMO[self._boilerstatus] # Maybe it is a valve - if self._room_id in self._data.room_status: - if ( - self._data.room_status[self._room_id].get("heating_power_request", 0) - > 0 - ): - return CURRENT_HVAC_HEAT + if self._room_status and self._room_status.get("heating_power_request", 0) > 0: + return CURRENT_HVAC_HEAT return CURRENT_HVAC_IDLE def set_hvac_mode(self, hvac_mode: str) -> None: @@ -268,33 +334,24 @@ class NetatmoThermostat(ClimateEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" if self.target_temperature == 0: - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, STATE_NETATMO_HOME, + self._home_status.set_room_thermpoint( + self._id, STATE_NETATMO_HOME, ) - if ( - preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] - and self._module_type == NA_VALVE - ): - self._data.homestatus.setroomThermpoint( - self._data.home_id, - self._room_id, - STATE_NETATMO_MANUAL, - DEFAULT_MAX_TEMP, + if preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] and self._model == NA_VALVE: + self._home_status.set_room_thermpoint( + self._id, STATE_NETATMO_MANUAL, DEFAULT_MAX_TEMP, ) elif preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX]: - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, PRESET_MAP_NETATMO[preset_mode] + self._home_status.set_room_thermpoint( + self._id, PRESET_MAP_NETATMO[preset_mode] ) elif preset_mode in [PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY]: - self._data.homestatus.setThermmode( - self._data.home_id, PRESET_MAP_NETATMO[preset_mode] - ) + self._home_status.set_thermmode(PRESET_MAP_NETATMO[preset_mode]) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) - self.update_without_throttle = True - self.schedule_update_ha_state() + self.async_write_ha_state() @property def preset_mode(self) -> Optional[str]: @@ -311,12 +368,9 @@ class NetatmoThermostat(ClimateEntity): temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: return - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, STATE_NETATMO_MANUAL, temp - ) + self._home_status.set_room_thermpoint(self._id, STATE_NETATMO_MANUAL, temp) - self.update_without_throttle = True - self.schedule_update_ha_state() + self.async_write_ha_state() @property def device_state_attributes(self): @@ -326,241 +380,147 @@ class NetatmoThermostat(ClimateEntity): if self._battery_level is not None: attr[ATTR_BATTERY_LEVEL] = self._battery_level + if self._model == NA_VALVE: + attr[ATTR_HEATING_POWER_REQUEST] = self._room_status.get( + "heating_power_request", 0 + ) + return attr def turn_off(self): """Turn the entity off.""" - if self._module_type == NA_VALVE: - self._data.homestatus.setroomThermpoint( - self._data.home_id, - self._room_id, - STATE_NETATMO_MANUAL, - DEFAULT_MIN_TEMP, + if self._model == NA_VALVE: + self._home_status.set_room_thermpoint( + self._id, STATE_NETATMO_MANUAL, DEFAULT_MIN_TEMP, ) elif self.hvac_mode != HVAC_MODE_OFF: - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, STATE_NETATMO_OFF - ) - self.update_without_throttle = True - self.schedule_update_ha_state() + self._home_status.set_room_thermpoint(self._id, STATE_NETATMO_OFF) + self.async_write_ha_state() def turn_on(self): """Turn the entity on.""" - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, STATE_NETATMO_HOME - ) - self.update_without_throttle = True - self.schedule_update_ha_state() + self._home_status.set_room_thermpoint(self._id, STATE_NETATMO_HOME) + self.async_write_ha_state() @property def available(self) -> bool: """If the device hasn't been able to connect, mark as unavailable.""" return bool(self._connected) - def update(self): - """Get the latest data from NetAtmo API and updates the states.""" + @callback + def async_update_callback(self): + """Update the entity's state.""" + self._home_status = self.data_handler.data[self._home_status_class] + self._room_status = self._home_status.rooms[self._id] + self._room_data = self._data.rooms[self._home_id][self._id] + + roomstatus = {"roomID": self._room_status["id"]} + if self._room_status.get("reachable"): + roomstatus.update(self._build_room_status()) + + self._away_temperature = self._data.get_away_temp(self._home_id) + self._hg_temperature = self._data.get_hg_temp(self._home_id) + self._setpoint_duration = self._data.setpoint_duration[self._home_id] + try: - if self.update_without_throttle: - self._data.update(no_throttle=True) - self.update_without_throttle = False - else: - self._data.update() - except AttributeError: - _LOGGER.error("NetatmoThermostat::update() got exception") - return - try: - if self._module_type is None: - self._module_type = self._data.room_status[self._room_id]["module_type"] - self._current_temperature = self._data.room_status[self._room_id][ - "current_temperature" - ] - self._target_temperature = self._data.room_status[self._room_id][ - "target_temperature" - ] - self._preset = NETATMO_MAP_PRESET[ - self._data.room_status[self._room_id]["setpoint_mode"] - ] + if self._model is None: + self._model = roomstatus["module_type"] + self._current_temperature = roomstatus["current_temperature"] + self._target_temperature = roomstatus["target_temperature"] + self._preset = NETATMO_MAP_PRESET[roomstatus["setpoint_mode"]] self._hvac_mode = HVAC_MAP_NETATMO[self._preset] - self._battery_level = self._data.room_status[self._room_id].get( - "battery_level" - ) + self._battery_level = roomstatus.get("battery_level") self._connected = True + except KeyError as err: - if self._connected is not False: + if self._connected: _LOGGER.debug( "The thermostat in room %s seems to be out of reach. (%s)", - self._room_name, + self._device_name, err, ) + self._connected = False + self._away = self._hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] - -class HomeData: - """Representation Netatmo homes.""" - - def __init__(self, auth, home=None): - """Initialize the HomeData object.""" - self.auth = auth - self.homedata = None - self.home_ids = [] - self.home_names = [] - self.room_names = [] - self.schedules = [] - self.home = home - self.home_id = None - - def get_all_home_ids(self): - """Get all the home ids returned by NetAtmo API.""" - if self.homedata is None: - return [] - for home_id in self.homedata.homes: - if ( - "therm_schedules" in self.homedata.homes[home_id] - and "modules" in self.homedata.homes[home_id] - ): - self.home_ids.append(self.homedata.homes[home_id]["id"]) - return self.home_ids - - def setup(self): - """Retrieve HomeData by NetAtmo API.""" + def _build_room_status(self): + """Construct room status.""" try: - self.homedata = pyatmo.HomeData(self.auth) - self.home_id = self.homedata.gethomeId(self.home) - except TypeError: - _LOGGER.error("Error when getting home data") - except AttributeError: - _LOGGER.error("No default_home in HomeData") - except pyatmo.NoDevice: - _LOGGER.debug("No thermostat devices available") - except pyatmo.InvalidHome: - _LOGGER.debug("Invalid home %s", self.home) + roomstatus = { + "roomname": self._room_data["name"], + "target_temperature": self._room_status["therm_setpoint_temperature"], + "setpoint_mode": self._room_status["therm_setpoint_mode"], + "current_temperature": self._room_status["therm_measured_temperature"], + "module_type": self._data.get_thermostat_type( + home_id=self._home_id, room_id=self._id + ), + "module_id": None, + "heating_status": None, + "heating_power_request": None, + } - -class ThermostatData: - """Get the latest data from Netatmo.""" - - def __init__(self, auth, home_id=None): - """Initialize the data object.""" - self.auth = auth - self.homedata = None - self.homestatus = None - self.room_ids = [] - self.room_status = {} - self.schedules = [] - self.home_id = home_id - self.home_name = None - self.away_temperature = None - self.hg_temperature = None - self.boilerstatus = None - self.setpoint_duration = None - - def get_room_ids(self): - """Return all module available on the API as a list.""" - if not self.setup(): - return [] - for room in self.homestatus.rooms: - self.room_ids.append(room) - return self.room_ids - - def setup(self): - """Retrieve HomeData and HomeStatus by NetAtmo API.""" - try: - self.homedata = pyatmo.HomeData(self.auth) - self.homestatus = pyatmo.HomeStatus(self.auth, home_id=self.home_id) - self.home_name = self.homedata.getHomeName(self.home_id) - self.update() - except TypeError: - _LOGGER.error("ThermostatData::setup() got error") - return False - except pyatmo.exceptions.NoDevice: - _LOGGER.debug( - "No climate devices for %s (%s)", self.home_name, self.home_id - ) - return False - return True - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Call the NetAtmo API to update the data.""" - try: - self.homestatus = pyatmo.HomeStatus(self.auth, home_id=self.home_id) - except pyatmo.exceptions.NoDevice: - _LOGGER.error("No device found") - return - except TypeError: - _LOGGER.error("Error when getting homestatus") - return - except requests.exceptions.Timeout: - _LOGGER.warning("Timed out when connecting to Netatmo server") - return - for room in self.homestatus.rooms: - try: - roomstatus = {} - homestatus_room = self.homestatus.rooms[room] - homedata_room = self.homedata.rooms[self.home_id][room] - - roomstatus["roomID"] = homestatus_room["id"] - if homestatus_room["reachable"]: - roomstatus["roomname"] = homedata_room["name"] - roomstatus["target_temperature"] = homestatus_room[ - "therm_setpoint_temperature" - ] - roomstatus["setpoint_mode"] = homestatus_room["therm_setpoint_mode"] - roomstatus["current_temperature"] = homestatus_room[ - "therm_measured_temperature" - ] - roomstatus["module_type"] = self.homestatus.thermostatType( - home_id=self.home_id, rid=room, home=self.home_name + batterylevel = None + for module_id in self._room_data["module_ids"]: + if ( + self._data.modules[self._home_id][module_id]["type"] == NA_THERM + or roomstatus["module_id"] is None + ): + roomstatus["module_id"] = module_id + if roomstatus["module_type"] == NA_THERM: + self._boilerstatus = self._home_status.boiler_status( + roomstatus["module_id"] + ) + roomstatus["heating_status"] = self._boilerstatus + batterylevel = self._home_status.thermostats[ + roomstatus["module_id"] + ].get("battery_level") + elif roomstatus["module_type"] == NA_VALVE: + roomstatus["heating_power_request"] = self._room_status[ + "heating_power_request" + ] + roomstatus["heating_status"] = roomstatus["heating_power_request"] > 0 + if self._boilerstatus is not None: + roomstatus["heating_status"] = ( + self._boilerstatus and roomstatus["heating_status"] ) - roomstatus["module_id"] = None - roomstatus["heating_status"] = None - roomstatus["heating_power_request"] = None - batterylevel = None - for module_id in homedata_room["module_ids"]: - if ( - self.homedata.modules[self.home_id][module_id]["type"] - == NA_THERM - or roomstatus["module_id"] is None - ): - roomstatus["module_id"] = module_id - if roomstatus["module_type"] == NA_THERM: - self.boilerstatus = self.homestatus.boilerStatus( - rid=roomstatus["module_id"] - ) - roomstatus["heating_status"] = self.boilerstatus - batterylevel = self.homestatus.thermostats[ - roomstatus["module_id"] - ].get("battery_level") - elif roomstatus["module_type"] == NA_VALVE: - roomstatus["heating_power_request"] = homestatus_room[ - "heating_power_request" - ] - roomstatus["heating_status"] = ( - roomstatus["heating_power_request"] > 0 - ) - if self.boilerstatus is not None: - roomstatus["heating_status"] = ( - self.boilerstatus and roomstatus["heating_status"] - ) - batterylevel = self.homestatus.valves[ - roomstatus["module_id"] - ].get("battery_level") + batterylevel = self._home_status.valves[roomstatus["module_id"]].get( + "battery_level" + ) - if batterylevel: - batterypct = interpolate( - batterylevel, roomstatus["module_type"] - ) - if roomstatus.get("battery_level") is None: - roomstatus["battery_level"] = batterypct - elif batterypct < roomstatus["battery_level"]: - roomstatus["battery_level"] = batterypct - self.room_status[room] = roomstatus - except KeyError as err: - _LOGGER.error("Update of room %s failed. Error: %s", room, err) - self.away_temperature = self.homestatus.getAwaytemp(home_id=self.home_id) - self.hg_temperature = self.homestatus.getHgtemp(home_id=self.home_id) - self.setpoint_duration = self.homedata.setpoint_duration[self.home_id] + if batterylevel: + batterypct = interpolate(batterylevel, roomstatus["module_type"]) + if ( + not roomstatus.get("battery_level") + or batterypct < roomstatus["battery_level"] + ): + roomstatus["battery_level"] = batterypct + + return roomstatus + + except KeyError as err: + _LOGGER.error("Update of room %s failed. Error: %s", self._id, err) + + return {} + + def _service_setschedule(self, **kwargs): + schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) + schedule_id = None + for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items(): + if name == schedule_name: + schedule_id = sid + + if not schedule_id: + _LOGGER.error("You passed an invalid schedule") + return + + self._data.switch_home_schedule(home_id=self._home_id, schedule_id=schedule_id) + _LOGGER.info( + "Setting %s schedule to %s (%s)", + self._home_id, + kwargs.get(ATTR_SCHEDULE_NAME), + schedule_id, + ) def interpolate(batterylevel, module_type): @@ -603,3 +563,17 @@ def interpolate(batterylevel, module_type): / (levels[i + 1] - levels[i]) ) return int(pct) + + +def get_all_home_ids(home_data): + """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 ( + "therm_schedules" in home_data.homes[home_id] + and "modules" in home_data.homes[home_id] + ) + ] diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 380878c6e73..eedac3229c0 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Netatmo.""" import logging +import uuid import voluptuous as vol @@ -16,6 +17,7 @@ from .const import ( CONF_LON_SW, CONF_NEW_AREA, CONF_PUBLIC_MODE, + CONF_UUID, CONF_WEATHER_AREAS, DOMAIN, ) @@ -66,6 +68,10 @@ class NetatmoFlowHandler( async def async_step_user(self, user_input=None): """Handle a flow start.""" await self.async_set_unique_id(DOMAIN) + + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="single_instance_allowed") + return await super().async_step_user(user_input) async def async_step_homekit(self, homekit_info): @@ -102,7 +108,7 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): user_input={CONF_NEW_AREA: new_client} ) - return await self._update_options() + return self._update_options() weather_areas = list(self.options[CONF_WEATHER_AREAS]) @@ -121,7 +127,14 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_public_weather(self, user_input=None): """Manage configuration of Netatmo public weather sensors.""" if user_input is not None and CONF_NEW_AREA not in user_input: - self.options[CONF_WEATHER_AREAS][user_input[CONF_AREA_NAME]] = user_input + self.options[CONF_WEATHER_AREAS][ + user_input[CONF_AREA_NAME] + ] = fix_coordinates(user_input) + + self.options[CONF_WEATHER_AREAS][user_input[CONF_AREA_NAME]][ + CONF_UUID + ] = str(uuid.uuid4()) + return await self.async_step_public_weather_areas() orig_options = self.config_entry.options.get(CONF_WEATHER_AREAS, {}).get( @@ -170,8 +183,30 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="public_weather", data_schema=data_schema) - async def _update_options(self): + def _update_options(self): """Update config entry options.""" return self.async_create_entry( title="Netatmo Public Weather", data=self.options ) + + +def fix_coordinates(user_input): + """Fix coordinates if they don't comply with the Netatmo API.""" + # Ensure coordinates have acceptable length for the Netatmo API + for coordinate in [CONF_LAT_NE, CONF_LAT_SW, CONF_LON_NE, CONF_LON_SW]: + if len(str(user_input[coordinate]).split(".")[1]) < 7: + user_input[coordinate] = user_input[coordinate] + 0.0000001 + + # Swap coordinates if entered in wrong order + if user_input[CONF_LAT_NE] < user_input[CONF_LAT_SW]: + user_input[CONF_LAT_NE], user_input[CONF_LAT_SW] = ( + user_input[CONF_LAT_SW], + user_input[CONF_LAT_NE], + ) + if user_input[CONF_LON_NE] < user_input[CONF_LON_SW]: + user_input[CONF_LON_NE], user_input[CONF_LON_SW] = ( + user_input[CONF_LON_SW], + user_input[CONF_LON_NE], + ) + + return user_input diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 835d42a32ba..c23b934c541 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -27,6 +27,8 @@ AUTH = "netatmo_auth" CONF_PUBLIC = "public_sensor_config" CAMERA_DATA = "netatmo_camera" HOME_DATA = "netatmo_home_data" +DATA_HANDLER = "netatmo_data_handler" +SIGNAL_NAME = "signal_name" CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_WEATHER_AREAS = "weather_areas" @@ -37,12 +39,15 @@ CONF_LON_NE = "lon_ne" CONF_LAT_SW = "lat_sw" CONF_LON_SW = "lon_sw" CONF_PUBLIC_MODE = "mode" +CONF_UUID = "uuid" OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize" OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token" DATA_DEVICE_IDS = "netatmo_device_ids" +DATA_HOMES = "netatmo_homes" DATA_PERSONS = "netatmo_persons" +DATA_SCHEDULES = "netatmo_schedules" NETATMO_WEBHOOK_URL = None NETATMO_EVENT = "netatmo_event" @@ -55,8 +60,10 @@ ATTR_ID = "id" ATTR_PSEUDO = "pseudo" ATTR_NAME = "name" ATTR_EVENT_TYPE = "event_type" +ATTR_HEATING_POWER_REQUEST = "heating_power_request" ATTR_HOME_ID = "home_id" ATTR_HOME_NAME = "home_name" +ATTR_PERSON = "person" ATTR_PERSONS = "persons" ATTR_IS_KNOWN = "is_known" ATTR_FACE_URL = "face_url" @@ -67,3 +74,5 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) MIN_TIME_BETWEEN_EVENT_UPDATES = timedelta(seconds=5) SERVICE_SETSCHEDULE = "set_schedule" +SERVICE_SETPERSONSHOME = "set_persons_home" +SERVICE_SETPERSONAWAY = "set_person_away" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py new file mode 100644 index 00000000000..414c89e13ec --- /dev/null +++ b/homeassistant/components/netatmo/data_handler.py @@ -0,0 +1,166 @@ +"""The Netatmo data handler.""" +from collections import deque +from datetime import timedelta +from functools import partial +from itertools import islice +import logging +from time import time +from typing import Deque, Dict, List + +import pyatmo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .const import AUTH, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +CAMERA_DATA_CLASS_NAME = "CameraData" +WEATHERSTATION_DATA_CLASS_NAME = "WeatherStationData" +HOMECOACH_DATA_CLASS_NAME = "HomeCoachData" +HOMEDATA_DATA_CLASS_NAME = "HomeData" +HOMESTATUS_DATA_CLASS_NAME = "HomeStatus" +PUBLICDATA_DATA_CLASS_NAME = "PublicData" + +NEXT_SCAN = "next_scan" + +DATA_CLASSES = { + WEATHERSTATION_DATA_CLASS_NAME: pyatmo.WeatherStationData, + HOMECOACH_DATA_CLASS_NAME: pyatmo.HomeCoachData, + CAMERA_DATA_CLASS_NAME: pyatmo.CameraData, + HOMEDATA_DATA_CLASS_NAME: pyatmo.HomeData, + HOMESTATUS_DATA_CLASS_NAME: pyatmo.HomeStatus, + PUBLICDATA_DATA_CLASS_NAME: pyatmo.PublicData, +} + +MAX_CALLS_1H = 20 +BATCH_SIZE = 3 +DEFAULT_INTERVALS = { + HOMEDATA_DATA_CLASS_NAME: 900, + HOMESTATUS_DATA_CLASS_NAME: 300, + CAMERA_DATA_CLASS_NAME: 900, + WEATHERSTATION_DATA_CLASS_NAME: 300, + HOMECOACH_DATA_CLASS_NAME: 300, + PUBLICDATA_DATA_CLASS_NAME: 600, +} +SCAN_INTERVAL = 60 + + +class NetatmoDataHandler: + """Manages the Netatmo data handling.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + """Initialize self.""" + self.hass = hass + self._auth = hass.data[DOMAIN][entry.entry_id][AUTH] + self.listeners: List[CALLBACK_TYPE] = [] + self._data_classes: Dict = {} + self.data = {} + self._queue: Deque = deque() + self._webhook: bool = False + + async def async_setup(self): + """Set up the Netatmo data handler.""" + + async_track_time_interval( + self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) + ) + + self.listeners.append( + self.hass.bus.async_listen("netatmo_event", self.handle_event) + ) + + async def async_update(self, event_time): + """ + Update device. + + We do up to BATCH_SIZE calls in one update in order + to minimize the calls on the api service. + """ + for data_class in islice(self._queue, 0, BATCH_SIZE): + if data_class[NEXT_SCAN] > time(): + continue + self._data_classes[data_class["name"]][NEXT_SCAN] = ( + time() + data_class["interval"] + ) + + await self.async_fetch_data( + data_class["class"], data_class["name"], **data_class["kwargs"] + ) + + self._queue.rotate(BATCH_SIZE) + + async def async_cleanup(self): + """Clean up the Netatmo data handler.""" + for listener in self.listeners: + listener() + + async def handle_event(self, event): + """Handle webhook events.""" + if event.data["data"]["push_type"] == "webhook_activation": + _LOGGER.info("%s webhook successfully registered", MANUFACTURER) + self._webhook = True + + elif event.data["data"]["push_type"] == "NACamera-connection": + _LOGGER.debug("%s camera reconnected", MANUFACTURER) + self._data_classes[CAMERA_DATA_CLASS_NAME][NEXT_SCAN] = time() + + async def async_fetch_data(self, data_class, data_class_entry, **kwargs): + """Fetch data and notify.""" + try: + self.data[data_class_entry] = await self.hass.async_add_executor_job( + partial(data_class, **kwargs), self._auth, + ) + for update_callback in self._data_classes[data_class_entry][ + "subscriptions" + ]: + if update_callback: + update_callback() + + except (pyatmo.NoDevice, pyatmo.ApiError) as err: + _LOGGER.debug(err) + + async def register_data_class( + self, data_class_name, data_class_entry, update_callback, **kwargs + ): + """Register data class.""" + if data_class_entry not in self._data_classes: + self._data_classes[data_class_entry] = { + "class": DATA_CLASSES[data_class_name], + "name": data_class_entry, + "interval": DEFAULT_INTERVALS[data_class_name], + NEXT_SCAN: time() + DEFAULT_INTERVALS[data_class_name], + "kwargs": kwargs, + "subscriptions": [update_callback], + } + + await self.async_fetch_data( + DATA_CLASSES[data_class_name], data_class_entry, **kwargs + ) + + self._queue.append(self._data_classes[data_class_entry]) + _LOGGER.debug("Data class %s added", data_class_entry) + + else: + self._data_classes[data_class_entry]["subscriptions"].append( + update_callback + ) + + async def unregister_data_class(self, data_class_entry, update_callback): + """Unregister data class.""" + if update_callback not in self._data_classes[data_class_entry]["subscriptions"]: + return + + self._data_classes[data_class_entry]["subscriptions"].remove(update_callback) + + if not self._data_classes[data_class_entry].get("subscriptions"): + self._queue.remove(self._data_classes[data_class_entry]) + self._data_classes.pop(data_class_entry) + _LOGGER.debug("Data class %s removed", data_class_entry) + + @property + def webhook(self) -> bool: + """Return the webhook state.""" + return self._webhook diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py new file mode 100644 index 00000000000..d9ef4d1e455 --- /dev/null +++ b/homeassistant/components/netatmo/helper.py @@ -0,0 +1,17 @@ +"""Helper for Netatmo integration.""" +from dataclasses import dataclass +from uuid import uuid4 + + +@dataclass +class NetatmoArea: + """Class for keeping track of an area.""" + + area_name: str + lat_ne: float + lon_ne: float + lat_sw: float + lon_sw: float + mode: str + show_on_map: bool + uuid: str = uuid4() diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py new file mode 100644 index 00000000000..56cf7945402 --- /dev/null +++ b/homeassistant/components/netatmo/light.py @@ -0,0 +1,145 @@ +"""Support for the Netatmo camera lights.""" +import logging + +import pyatmo + +from homeassistant.components.light import LightEntity +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady + +from .const import DATA_HANDLER, DOMAIN, MANUFACTURER, SIGNAL_NAME +from .data_handler import CAMERA_DATA_CLASS_NAME, NetatmoDataHandler +from .netatmo_entity_base import NetatmoBase + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Netatmo camera light platform.""" + if "access_camera" not in entry.data["token"]["scope"]: + _LOGGER.info( + "Cameras are currently not supported with this authentication method" + ) + return + + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] + + async def get_entities(): + """Retrieve Netatmo entities.""" + await data_handler.register_data_class( + CAMERA_DATA_CLASS_NAME, CAMERA_DATA_CLASS_NAME, None + ) + + entities = [] + try: + all_cameras = [] + for home in data_handler.data[CAMERA_DATA_CLASS_NAME].cameras.values(): + for camera in home.values(): + all_cameras.append(camera) + + for camera in all_cameras: + if camera["type"] == "NOC": + if not data_handler.webhook: + raise PlatformNotReady + + _LOGGER.debug( + "Adding camera light %s %s", camera["id"], camera["name"] + ) + entities.append( + NetatmoLight( + data_handler, + camera["id"], + camera["type"], + camera["home_id"], + ) + ) + + except pyatmo.NoDevice: + _LOGGER.debug("No cameras found") + + return entities + + async_add_entities(await get_entities(), True) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Netatmo camera platform.""" + return + + +class NetatmoLight(NetatmoBase, LightEntity): + """Representation of a Netatmo Presence camera light.""" + + def __init__( + self, + data_handler: NetatmoDataHandler, + camera_id: str, + camera_type: str, + home_id: str, + ): + """Initialize a Netatmo Presence camera light.""" + LightEntity.__init__(self) + super().__init__(data_handler) + + self._data_classes.append( + {"name": CAMERA_DATA_CLASS_NAME, SIGNAL_NAME: CAMERA_DATA_CLASS_NAME} + ) + self._id = camera_id + self._home_id = home_id + self._model = camera_type + self._device_name = self._data.get_camera(camera_id).get("name") + self._name = f"{MANUFACTURER} {self._device_name}" + self._is_on = False + self._unique_id = f"{self._id}-light" + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + + self._listeners.append( + self.hass.bus.async_listen("netatmo_event", self.handle_event) + ) + + async def handle_event(self, event): + """Handle webhook events.""" + data = event.data["data"] + + if not data.get("event_type"): + return + + if not data.get("camera_id"): + return + + if ( + data["home_id"] == self._home_id + and data["camera_id"] == self._id + and data["push_type"] == "NOC-light_mode" + ): + self._is_on = bool(data["sub_type"] == "on") + + self.async_write_ha_state() + return + + @property + def is_on(self): + """Return true if light is on.""" + return self._is_on + + def turn_on(self, **kwargs): + """Turn camera floodlight on.""" + _LOGGER.debug("Turn camera '%s' on", self._name) + self._data.set_state( + home_id=self._home_id, camera_id=self._id, floodlight="on", + ) + + def turn_off(self, **kwargs): + """Turn camera floodlight into auto mode.""" + _LOGGER.debug("Turn camera '%s' off", self._name) + self._data.set_state( + home_id=self._home_id, camera_id=self._id, floodlight="auto", + ) + + @callback + def async_update_callback(self): + """Update the entity's state.""" + self._is_on = bool(self._data.get_light_state(self._id) == "on") diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index ece1b33c608..fe8c5367093 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==3.3.1" + "pyatmo==4.0.0" ], "after_dependencies": [ "cloud" diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py new file mode 100644 index 00000000000..6bae7d54168 --- /dev/null +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -0,0 +1,113 @@ +"""Base class for Netatmo entities.""" +import logging +from typing import Dict, List + +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER, MODELS, SIGNAL_NAME +from .data_handler import NetatmoDataHandler + +_LOGGER = logging.getLogger(__name__) + + +class NetatmoBase(Entity): + """Netatmo entity base class.""" + + def __init__(self, data_handler: NetatmoDataHandler) -> None: + """Set up Netatmo entity base.""" + self.data_handler = data_handler + self._data_classes: List[Dict] = [] + self._listeners: List[CALLBACK_TYPE] = [] + + self._device_name = None + self._id = None + self._model = None + self._name = None + self._unique_id = None + + async def async_added_to_hass(self) -> None: + """Entity created.""" + _LOGGER.debug("New client %s", self.entity_id) + for data_class in self._data_classes: + signal_name = data_class[SIGNAL_NAME] + + if "home_id" in data_class: + await self.data_handler.register_data_class( + data_class["name"], + signal_name, + self.async_update_callback, + home_id=data_class["home_id"], + ) + + elif data_class["name"] == "PublicData": + await self.data_handler.register_data_class( + data_class["name"], + signal_name, + self.async_update_callback, + LAT_NE=data_class["LAT_NE"], + LON_NE=data_class["LON_NE"], + LAT_SW=data_class["LAT_SW"], + LON_SW=data_class["LON_SW"], + ) + + else: + await self.data_handler.register_data_class( + data_class["name"], signal_name, self.async_update_callback + ) + + await self.data_handler.unregister_data_class(signal_name, None) + + self.async_update_callback() + + async def async_will_remove_from_hass(self): + """Run when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + + for listener in self._listeners: + listener() + + for data_class in self._data_classes: + await self.data_handler.unregister_data_class( + data_class[SIGNAL_NAME], self.async_update_callback + ) + + async def async_remove(self): + """Clean up when removing entity.""" + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + entity_entry = entity_registry.async_get(self.entity_id) + if not entity_entry: + await super().async_remove() + return + + entity_registry.async_remove(self.entity_id) + + @callback + def async_update_callback(self): + """Update the entity's state.""" + raise NotImplementedError + + @property + def _data(self): + """Return data for this entity.""" + return self.data_handler.data[self._data_classes[0]["name"]] + + @property + def unique_id(self): + """Return the unique ID of this entity.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity.""" + return self._name + + @property + def device_info(self): + """Return the device info for the sensor.""" + return { + "identifiers": {(DOMAIN, self._id)}, + "name": self._device_name, + "manufacturer": MANUFACTURER, + "model": MODELS[self._model], + } diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 6aaa7d08975..2352b4abee8 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,15 +1,11 @@ """Support for the Netatmo Weather Service.""" -from datetime import timedelta import logging -import pyatmo - from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONCENTRATION_PARTS_PER_MILLION, - CONF_SHOW_ON_MAP, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -23,31 +19,18 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -from .const import ( - AUTH, - CONF_AREA_NAME, - CONF_LAT_NE, - CONF_LAT_SW, - CONF_LON_NE, - CONF_LON_SW, - CONF_PUBLIC_MODE, - CONF_WEATHER_AREAS, - DOMAIN, - MANUFACTURER, - MODELS, +from .const import CONF_WEATHER_AREAS, DATA_HANDLER, DOMAIN, MANUFACTURER, SIGNAL_NAME +from .data_handler import ( + HOMECOACH_DATA_CLASS_NAME, + PUBLICDATA_DATA_CLASS_NAME, + WEATHERSTATION_DATA_CLASS_NAME, ) +from .helper import NetatmoArea +from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) -# This is the Netatmo data upload interval in seconds -NETATMO_UPDATE_INTERVAL = 600 - -# NetAtmo Public Data is uploaded to server every 10 minutes -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=NETATMO_UPDATE_INTERVAL) - SUPPORTED_PUBLIC_SENSOR_TYPES = [ "temperature", "pressure", @@ -76,11 +59,11 @@ SENSOR_TYPES = { DEVICE_CLASS_HUMIDITY, ], "rain": ["Rain", "mm", "mdi:weather-rainy", None], - "sum_rain_1": ["sum_rain_1", "mm", "mdi:weather-rainy", None], - "sum_rain_24": ["sum_rain_24", "mm", "mdi:weather-rainy", None], + "sum_rain_1": ["Rain last hour", "mm", "mdi:weather-rainy", None], + "sum_rain_24": ["Rain last 24h", "mm", "mdi:weather-rainy", None], "battery_vp": ["Battery", "", "mdi:battery", None], - "battery_lvl": ["Battery_lvl", "", "mdi:battery", None], - "battery_percent": ["battery_percent", UNIT_PERCENTAGE, None, DEVICE_CLASS_BATTERY], + "battery_lvl": ["Battery Level", "", "mdi:battery", None], + "battery_percent": ["Battery Percent", UNIT_PERCENTAGE, None, DEVICE_CLASS_BATTERY], "min_temp": ["Min Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "max_temp": ["Max Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "windangle": ["Angle", "", "mdi:compass", None], @@ -101,9 +84,9 @@ SENSOR_TYPES = { ], "reachable": ["Reachability", "", "mdi:signal", None], "rf_status": ["Radio", "", "mdi:signal", None], - "rf_status_lvl": ["Radio_lvl", "", "mdi:signal", None], + "rf_status_lvl": ["Radio Level", "", "mdi:signal", None], "wifi_status": ["Wifi", "", "mdi:wifi", None], - "wifi_status_lvl": ["Wifi_lvl", "dBm", "mdi:wifi", None], + "wifi_status_lvl": ["Wifi Level", "dBm", "mdi:wifi", None], "health_idx": ["Health", "", "mdi:cloud", None], } @@ -112,76 +95,110 @@ MODULE_TYPE_WIND = "NAModule2" MODULE_TYPE_RAIN = "NAModule3" MODULE_TYPE_INDOOR = "NAModule4" - -NETATMO_DEVICE_TYPES = { - "WeatherStationData": "weather station", - "HomeCoachData": "home coach", +BATTERY_VALUES = { + MODULE_TYPE_WIND: {"Full": 5590, "High": 5180, "Medium": 4770, "Low": 4360}, + MODULE_TYPE_RAIN: {"Full": 5500, "High": 5000, "Medium": 4500, "Low": 4000}, + MODULE_TYPE_INDOOR: {"Full": 5500, "High": 5280, "Medium": 4920, "Low": 4560}, + MODULE_TYPE_OUTDOOR: {"Full": 5500, "High": 5000, "Medium": 4500, "Low": 4000}, } PUBLIC = "public" -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): +async def async_setup_entry(hass, entry, async_add_entities): """Set up the Netatmo weather and homecoach platform.""" - auth = hass.data[DOMAIN][entry.entry_id][AUTH] device_registry = await hass.helpers.device_registry.async_get_registry() + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - def find_entities(data): + async def find_entities(data_class_name): """Find all entities.""" - all_module_infos = data.get_module_infos() + await data_handler.register_data_class(data_class_name, data_class_name, None) + + all_module_infos = {} + data = data_handler.data + + if not data.get(data_class_name): + return [] + + data_class = data[data_class_name] + + for station_id in data_class.stations: + for module_id in data_class.get_modules(station_id): + all_module_infos[module_id] = data_class.get_module(module_id) + + all_module_infos[station_id] = data_class.get_station(station_id) + entities = [] for module in all_module_infos.values(): - _LOGGER.debug("Adding module %s %s", module["module_name"], module["id"]) - for condition in data.station_data.monitoredConditions( - moduleId=module["id"] - ): - entities.append(NetatmoSensor(data, module, condition.lower())) - return entities - - def get_entities(): - """Retrieve Netatmo entities.""" - entities = [] - - for data_class in [pyatmo.WeatherStationData, pyatmo.HomeCoachData]: - try: - dc_data = data_class(auth) - _LOGGER.debug("%s detected!", NETATMO_DEVICE_TYPES[data_class.__name__]) - data = NetatmoData(auth, dc_data) - except pyatmo.NoDevice: - _LOGGER.debug( - "No %s entities found", NETATMO_DEVICE_TYPES[data_class.__name__] - ) + if "_id" not in module: + _LOGGER.debug("Skipping module %s", module.get("module_name")) continue - entities.extend(find_entities(data)) + _LOGGER.debug( + "Adding module %s %s", module.get("module_name"), module.get("_id"), + ) + for condition in data_class.get_monitored_conditions( + module_id=module["_id"] + ): + entities.append( + NetatmoSensor( + data_handler, data_class_name, module, condition.lower() + ) + ) return entities - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for data_class_name in [ + WEATHERSTATION_DATA_CLASS_NAME, + HOMECOACH_DATA_CLASS_NAME, + ]: + async_add_entities(await find_entities(data_class_name), True) @callback - def add_public_entities(): + async def add_public_entities(update=True): """Retrieve Netatmo public weather entities.""" - entities = [] - for area in entry.options.get(CONF_WEATHER_AREAS, {}).values(): - data = NetatmoPublicData( - auth, - lat_ne=area[CONF_LAT_NE], - lon_ne=area[CONF_LON_NE], - lat_sw=area[CONF_LAT_SW], - lon_sw=area[CONF_LON_SW], + entities = { + device.name: device.id + for device in async_entries_for_config_entry( + device_registry, entry.entry_id + ) + if device.model == "Public Weather stations" + } + + new_entities = [] + for area in [ + NetatmoArea(**i) for i in entry.options.get(CONF_WEATHER_AREAS, {}).values() + ]: + signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" + + if area.area_name in entities: + entities.pop(area.area_name) + + if update: + async_dispatcher_send( + hass, f"netatmo-config-{area.area_name}", area, + ) + continue + + await data_handler.register_data_class( + PUBLICDATA_DATA_CLASS_NAME, + signal_name, + None, + LAT_NE=area.lat_ne, + LON_NE=area.lon_ne, + LAT_SW=area.lat_sw, + LON_SW=area.lon_sw, ) for sensor_type in SUPPORTED_PUBLIC_SENSOR_TYPES: - entities.append(NetatmoPublicSensor(area, data, sensor_type,)) + new_entities.append( + NetatmoPublicSensor(data_handler, area, sensor_type) + ) - for device in async_entries_for_config_entry(device_registry, entry.entry_id): - if device.model == "Public Weather stations": - device_registry.async_remove_device(device.id) + for device_id in entities.values(): + device_registry.async_remove_device(device_id) - if entities: - async_add_entities(entities) + if new_entities: + async_add_entities(new_entities) async_dispatcher_connect( hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}", add_public_entities @@ -189,7 +206,7 @@ async def async_setup_entry( entry.add_update_listener(async_config_entry_updated) - add_public_entities() + await add_public_entities(False) async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -202,39 +219,42 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= return -class NetatmoSensor(Entity): +class NetatmoSensor(NetatmoBase): """Implementation of a Netatmo sensor.""" - def __init__(self, netatmo_data, module_info, sensor_type): + def __init__(self, data_handler, data_class_name, module_info, sensor_type): """Initialize the sensor.""" - self.netatmo_data = netatmo_data + super().__init__(data_handler) + + self._data_classes.append( + {"name": data_class_name, SIGNAL_NAME: data_class_name} + ) + + self._id = module_info["_id"] + self._station_id = module_info.get("main_device", self._id) + + station = self._data.get_station(self._station_id) + device = self._data.get_module(self._id) - device = self.netatmo_data.station_data.moduleById(mid=module_info["id"]) if not device: # Assume it's a station if module can't be found - device = self.netatmo_data.station_data.stationById(sid=module_info["id"]) + device = station - if device["type"] == "NHC": - self.module_name = module_info["station_name"] + if device["type"] in ("NHC", "NAMain"): + self._device_name = module_info["station_name"] else: - self.module_name = ( - f"{module_info['station_name']} {module_info['module_name']}" - ) + self._device_name = f"{station['station_name']} {module_info.get('module_name', device['type'])}" - self._name = f"{MANUFACTURER} {self.module_name} {SENSOR_TYPES[sensor_type][0]}" + self._name = ( + f"{MANUFACTURER} {self._device_name} {SENSOR_TYPES[sensor_type][0]}" + ) self.type = sensor_type self._state = None self._device_class = SENSOR_TYPES[self.type][3] self._icon = SENSOR_TYPES[self.type][2] self._unit_of_measurement = SENSOR_TYPES[self.type][1] - self._module_type = device["type"] - self._module_id = module_info["id"] - self._unique_id = f"{self._module_id}-{self.type}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._model = device["type"] + self._unique_id = f"{self._id}-{self.type}" @property def icon(self): @@ -246,16 +266,6 @@ class NetatmoSensor(Entity): """Return the device class of the sensor.""" return self._device_class - @property - def device_info(self): - """Return the device info for the sensor.""" - return { - "identifiers": {(DOMAIN, self._module_id)}, - "name": self.module_name, - "manufacturer": MANUFACTURER, - "model": MODELS[self._module_type], - } - @property def state(self): """Return the state of the device.""" @@ -266,34 +276,33 @@ class NetatmoSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - @property def available(self): - """Return True if entity is available.""" + """Return entity availability.""" return self._state is not None - def update(self): - """Get the latest data from Netatmo API and updates the states.""" - self.netatmo_data.update() - if self.netatmo_data.data is None: + @callback + def async_update_callback(self): + """Update the entity's state.""" + if self._data is None: if self._state is None: return _LOGGER.warning("No data from update") self._state = None return - data = self.netatmo_data.data.get(self._module_id) + data = self._data.get_last_data(station_id=self._station_id, exclude=3600).get( + self._id + ) if data is None: if self._state: _LOGGER.debug( - "No data found for %s (%s)", self.module_name, self._module_id + "No data (%s) found for %s (%s)", + self._data, + self._device_name, + self._id, ) - _LOGGER.debug("data: %s", self.netatmo_data.data) self._state = None return @@ -318,50 +327,8 @@ class NetatmoSensor(Entity): self._state = data["battery_percent"] elif self.type == "battery_lvl": self._state = data["battery_vp"] - elif self.type == "battery_vp" and self._module_type == MODULE_TYPE_WIND: - if data["battery_vp"] >= 5590: - self._state = "Full" - elif data["battery_vp"] >= 5180: - self._state = "High" - elif data["battery_vp"] >= 4770: - self._state = "Medium" - elif data["battery_vp"] >= 4360: - self._state = "Low" - elif data["battery_vp"] < 4360: - self._state = "Very Low" - elif self.type == "battery_vp" and self._module_type == MODULE_TYPE_RAIN: - if data["battery_vp"] >= 5500: - self._state = "Full" - elif data["battery_vp"] >= 5000: - self._state = "High" - elif data["battery_vp"] >= 4500: - self._state = "Medium" - elif data["battery_vp"] >= 4000: - self._state = "Low" - elif data["battery_vp"] < 4000: - self._state = "Very Low" - elif self.type == "battery_vp" and self._module_type == MODULE_TYPE_INDOOR: - if data["battery_vp"] >= 5640: - self._state = "Full" - elif data["battery_vp"] >= 5280: - self._state = "High" - elif data["battery_vp"] >= 4920: - self._state = "Medium" - elif data["battery_vp"] >= 4560: - self._state = "Low" - elif data["battery_vp"] < 4560: - self._state = "Very Low" - elif self.type == "battery_vp" and self._module_type == MODULE_TYPE_OUTDOOR: - if data["battery_vp"] >= 5500: - self._state = "Full" - elif data["battery_vp"] >= 5000: - self._state = "High" - elif data["battery_vp"] >= 4500: - self._state = "Medium" - elif data["battery_vp"] >= 4000: - self._state = "Low" - elif data["battery_vp"] < 4000: - self._state = "Very Low" + elif self.type == "battery_vp": + self._state = process_battery(data["battery_vp"], self._model) elif self.type == "min_temp": self._state = data["min_temp"] elif self.type == "max_temp": @@ -369,47 +336,13 @@ class NetatmoSensor(Entity): elif self.type == "windangle_value": self._state = data["WindAngle"] elif self.type == "windangle": - if data["WindAngle"] >= 330: - self._state = "N (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 300: - self._state = "NW (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 240: - self._state = "W (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 210: - self._state = "SW (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 150: - self._state = "S (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 120: - self._state = "SE (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 60: - self._state = "E (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 30: - self._state = "NE (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 0: - self._state = "N (%d\xb0)" % data["WindAngle"] + self._state = process_angle(data["WindAngle"]) elif self.type == "windstrength": self._state = data["WindStrength"] elif self.type == "gustangle_value": self._state = data["GustAngle"] elif self.type == "gustangle": - if data["GustAngle"] >= 330: - self._state = "N (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 300: - self._state = "NW (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 240: - self._state = "W (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 210: - self._state = "SW (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 150: - self._state = "S (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 120: - self._state = "SE (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 60: - self._state = "E (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 30: - self._state = "NE (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 0: - self._state = "N (%d\xb0)" % data["GustAngle"] + self._state = process_angle(data["GustAngle"]) elif self.type == "guststrength": self._state = data["GustStrength"] elif self.type == "reachable": @@ -417,90 +350,127 @@ class NetatmoSensor(Entity): elif self.type == "rf_status_lvl": self._state = data["rf_status"] elif self.type == "rf_status": - if data["rf_status"] >= 90: - self._state = "Low" - elif data["rf_status"] >= 76: - self._state = "Medium" - elif data["rf_status"] >= 60: - self._state = "High" - elif data["rf_status"] <= 59: - self._state = "Full" + self._state = process_rf(data["rf_status"]) elif self.type == "wifi_status_lvl": self._state = data["wifi_status"] elif self.type == "wifi_status": - if data["wifi_status"] >= 86: - self._state = "Low" - elif data["wifi_status"] >= 71: - self._state = "Medium" - elif data["wifi_status"] >= 56: - self._state = "High" - elif data["wifi_status"] <= 55: - self._state = "Full" + self._state = process_wifi(data["wifi_status"]) elif self.type == "health_idx": - if data["health_idx"] == 0: - self._state = "Healthy" - elif data["health_idx"] == 1: - self._state = "Fine" - elif data["health_idx"] == 2: - self._state = "Fair" - elif data["health_idx"] == 3: - self._state = "Poor" - elif data["health_idx"] == 4: - self._state = "Unhealthy" + self._state = process_health(data["health_idx"]) except KeyError: if self._state: - _LOGGER.info("No %s data found for %s", self.type, self.module_name) + _LOGGER.debug("No %s data found for %s", self.type, self._device_name) self._state = None return -class NetatmoData: - """Get the latest data from Netatmo.""" - - def __init__(self, auth, station_data): - """Initialize the data object.""" - self.data = {} - self.station_data = station_data - self.auth = auth - - def get_module_infos(self): - """Return all modules available on the API as a dict.""" - return self.station_data.getModules() - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Call the Netatmo API to update the data.""" - self.station_data = self.station_data.__class__(self.auth) - - data = self.station_data.lastData(exclude=3600, byId=True) - if not data: - _LOGGER.debug("No data received when updating station data") - return - self.data = data +def process_angle(angle: int) -> str: + """Process angle and return string for display.""" + if angle >= 330: + return f"N ({angle}\xb0)" + if angle >= 300: + return f"NW ({angle}\xb0)" + if angle >= 240: + return f"W ({angle}\xb0)" + if angle >= 210: + return f"SW ({angle}\xb0)" + if angle >= 150: + return f"S ({angle}\xb0)" + if angle >= 120: + return f"SE ({angle}\xb0)" + if angle >= 60: + return f"E ({angle}\xb0)" + if angle >= 30: + return f"NE ({angle}\xb0)" + return f"N ({angle}\xb0)" -class NetatmoPublicSensor(Entity): +def process_battery(data: int, model: str) -> str: + """Process battery data and return string for display.""" + values = BATTERY_VALUES[model] + + if data >= values["Full"]: + return "Full" + if data >= values["High"]: + return "High" + if data >= values["Medium"]: + return "Medium" + if data >= values["Low"]: + return "Low" + return "Very Low" + + +def process_health(health): + """Process health index and return string for display.""" + if health == 0: + return "Healthy" + if health == 1: + return "Fine" + if health == 2: + return "Fair" + if health == 3: + return "Poor" + if health == 4: + return "Unhealthy" + + +def process_rf(strength): + """Process wifi signal strength and return string for display.""" + if strength >= 90: + return "Low" + if strength >= 76: + return "Medium" + if strength >= 60: + return "High" + return "Full" + + +def process_wifi(strength): + """Process wifi signal strength and return string for display.""" + if strength >= 86: + return "Low" + if strength >= 71: + return "Medium" + if strength >= 56: + return "High" + return "Full" + + +class NetatmoPublicSensor(NetatmoBase): """Represent a single sensor in a Netatmo.""" - def __init__(self, area, data, sensor_type): + def __init__(self, data_handler, area, sensor_type): """Initialize the sensor.""" - self.netatmo_data = data + super().__init__(data_handler) + + self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" + + self._data_classes.append( + { + "name": PUBLICDATA_DATA_CLASS_NAME, + "LAT_NE": area.lat_ne, + "LON_NE": area.lon_ne, + "LAT_SW": area.lat_sw, + "LON_SW": area.lon_sw, + "area_name": area.area_name, + SIGNAL_NAME: self._signal_name, + } + ) + self.type = sensor_type - self._mode = area[CONF_PUBLIC_MODE] - self._area_name = area[CONF_AREA_NAME] - self._name = f"{MANUFACTURER} {self._area_name} {SENSOR_TYPES[self.type][0]}" + self.area = area + self._mode = area.mode + self._area_name = area.area_name + self._id = self._area_name + self._device_name = f"{self._area_name}" + self._name = f"{MANUFACTURER} {self._device_name} {SENSOR_TYPES[self.type][0]}" self._state = None self._device_class = SENSOR_TYPES[self.type][3] self._icon = SENSOR_TYPES[self.type][2] self._unit_of_measurement = SENSOR_TYPES[self.type][1] - self._show_on_map = area[CONF_SHOW_ON_MAP] - self._unique_id = f"{self._name.replace(' ', '-')}" - self._module_type = PUBLIC - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._show_on_map = area.show_on_map + self._unique_id = f"{self._device_name.replace(' ', '-')}-{self.type}" + self._model = PUBLIC @property def icon(self): @@ -512,28 +482,14 @@ class NetatmoPublicSensor(Entity): """Return the device class of the sensor.""" return self._device_class - @property - def device_info(self): - """Return the device info for the sensor.""" - return { - "identifiers": {(DOMAIN, self._area_name)}, - "name": self._area_name, - "manufacturer": MANUFACTURER, - "model": MODELS[self._module_type], - } - @property def device_state_attributes(self): """Return the attributes of the device.""" attrs = {} if self._show_on_map: - attrs[ATTR_LATITUDE] = ( - self.netatmo_data.lat_ne + self.netatmo_data.lat_sw - ) / 2 - attrs[ATTR_LONGITUDE] = ( - self.netatmo_data.lon_ne + self.netatmo_data.lon_sw - ) / 2 + attrs[ATTR_LATITUDE] = (self.area.lat_ne + self.area.lat_sw) / 2 + attrs[ATTR_LONGITUDE] = (self.area.lon_ne + self.area.lon_sw) / 2 return attrs @@ -547,46 +503,95 @@ class NetatmoPublicSensor(Entity): """Return the unit of measurement of this entity.""" return self._unit_of_measurement - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - @property def available(self): """Return True if entity is available.""" return self._state is not None - def update(self): - """Get the latest data from Netatmo API and updates the states.""" - self.netatmo_data.update() + @property + def _data(self): + return self.data_handler.data[self._signal_name] - if self.netatmo_data.data is None: - _LOGGER.info("No data found for %s", self._name) + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + + self.data_handler.listeners.append( + async_dispatcher_connect( + self.hass, + f"netatmo-config-{self.device_info['name']}", + self.async_config_update_callback, + ) + ) + + @callback + async def async_config_update_callback(self, area): + """Update the entity's config.""" + if self.area == area: + return + + await self.data_handler.unregister_data_class( + self._signal_name, self.async_update_callback + ) + + self.area = area + self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" + self._data_classes = [ + { + "name": PUBLICDATA_DATA_CLASS_NAME, + "LAT_NE": area.lat_ne, + "LON_NE": area.lon_ne, + "LAT_SW": area.lat_sw, + "LON_SW": area.lon_sw, + "area_name": area.area_name, + SIGNAL_NAME: self._signal_name, + } + ] + self._mode = area.mode + self._show_on_map = area.show_on_map + await self.data_handler.register_data_class( + PUBLICDATA_DATA_CLASS_NAME, + self._signal_name, + self.async_update_callback, + LAT_NE=area.lat_ne, + LON_NE=area.lon_ne, + LAT_SW=area.lat_sw, + LON_SW=area.lon_sw, + ) + + @callback + def async_update_callback(self): + """Update the entity's state.""" + if self._data is None: + if self._state is None: + return + _LOGGER.warning("No data from update") self._state = None return data = None if self.type == "temperature": - data = self.netatmo_data.data.getLatestTemperatures() + data = self._data.get_latest_temperatures() elif self.type == "pressure": - data = self.netatmo_data.data.getLatestPressures() + data = self._data.get_latest_pressures() elif self.type == "humidity": - data = self.netatmo_data.data.getLatestHumidities() + data = self._data.get_latest_humidities() elif self.type == "rain": - data = self.netatmo_data.data.getLatestRain() + data = self._data.get_latest_rain() elif self.type == "sum_rain_1": - data = self.netatmo_data.data.get60minRain() + data = self._data.get_60_min_rain() elif self.type == "sum_rain_24": - data = self.netatmo_data.data.get24hRain() + data = self._data.get_24_h_rain() elif self.type == "windstrength": - data = self.netatmo_data.data.getLatestWindStrengths() + data = self._data.get_latest_wind_strengths() elif self.type == "guststrength": - data = self.netatmo_data.data.getLatestGustStrengths() + data = self._data.get_latest_gust_strengths() if not data: - _LOGGER.warning( + if self._state is None: + return + _LOGGER.debug( "No station provides %s data in the area %s", self.type, self._area_name ) self._state = None @@ -597,41 +602,3 @@ class NetatmoPublicSensor(Entity): self._state = round(sum(values) / len(values), 1) elif self._mode == "max": self._state = max(values) - - -class NetatmoPublicData: - """Get the latest data from Netatmo.""" - - def __init__(self, auth, lat_ne, lon_ne, lat_sw, lon_sw): - """Initialize the data object.""" - self.auth = auth - self.data = None - self.lat_ne = lat_ne - self.lon_ne = lon_ne - self.lat_sw = lat_sw - self.lon_sw = lon_sw - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Request an update from the Netatmo API.""" - try: - data = pyatmo.PublicData( - self.auth, - LAT_NE=self.lat_ne, - LON_NE=self.lon_ne, - LAT_SW=self.lat_sw, - LON_SW=self.lon_sw, - filtering=True, - ) - except pyatmo.NoDevice: - data = None - - if not data: - _LOGGER.debug("No data received when updating public station data") - return - - if data.CountStationInArea() == 0: - _LOGGER.warning("No Stations available in this area") - return - - self.data = data diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index 46de69b5cb3..bd8a0cc8f20 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -3,8 +3,34 @@ set_schedule: description: Set the heating schedule. fields: schedule_name: - description: Schedule name. + description: Schedule name example: Standard - home_name: - description: Home name. - example: MyHome + entity_id: + description: Entity id of the climate device. + example: climate.netatmo_livingroom + +set_persons_home: + description: Set a list of persons as at home. Person's name must match a name known by the Welcome Camera. + fields: + persons: + description: List of names + example: Bob + entity_id: + description: Entity id of the camera. + example: camera.netatmo_entrance + +set_person_away: + description: Set a person away. If no person is set the home will be marked as empty. Person's name must match a name known by the Welcome Camera. + fields: + person: + description: Person's name (optional) + example: Bob + entity_id: + description: Entity id of the camera. + example: camera.netatmo_entrance + +register_webhook: + description: Register webhook + +unregister_webhook: + description: Unregister webhook diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 116a37adb55..f1b761dd187 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -6,7 +6,7 @@ } }, "abort": { - "already_setup": "[%key:common::config_flow::abort::single_instance_allowed%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]" }, diff --git a/homeassistant/components/netatmo/translations/en.json b/homeassistant/components/netatmo/translations/en.json index 8176f4f057e..a96dfcbec6c 100644 --- a/homeassistant/components/netatmo/translations/en.json +++ b/homeassistant/components/netatmo/translations/en.json @@ -3,7 +3,8 @@ "abort": { "already_setup": "Already configured. Only a single configuration possible.", "authorize_url_timeout": "Timeout generating authorize URL.", - "missing_configuration": "The component is not configured. Please follow the documentation." + "missing_configuration": "The component is not configured. Please follow the documentation.", + "single_instance_allowed": "Already configured. Only a single configuration possible." }, "create_entry": { "default": "Successfully authenticated" diff --git a/homeassistant/components/netatmo/translations/it.json b/homeassistant/components/netatmo/translations/it.json index 7095a058feb..1efe744687d 100644 --- a/homeassistant/components/netatmo/translations/it.json +++ b/homeassistant/components/netatmo/translations/it.json @@ -13,5 +13,30 @@ "title": "Scegli il metodo di autenticazione" } } + }, + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "Nome dell'area", + "lat_ne": "Latitudine angolo Nord-Est", + "lat_sw": "Latitudine angolo Sud-Ovest", + "lon_ne": "Longitudine angolo Nord-Est", + "lon_sw": "Longitudine angolo Sud-Ovest", + "mode": "Calcolo", + "show_on_map": "Mostra sulla mappa" + }, + "description": "Configurare un sensore meteorologico pubblico per un'area.", + "title": "Sensore meteorologico pubblico Netatmo" + }, + "public_weather_areas": { + "data": { + "new_area": "Nome area", + "weather_areas": "Aree meteorologiche" + }, + "description": "Configura i sensori meteorologici pubblici.", + "title": "Sensore meteorologico pubblico Netatmo" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/lb.json b/homeassistant/components/netatmo/translations/lb.json index 3605a76b372..86212bb5b9f 100644 --- a/homeassistant/components/netatmo/translations/lb.json +++ b/homeassistant/components/netatmo/translations/lb.json @@ -13,5 +13,23 @@ "title": "Authentifikatiouns Method auswielen" } } + }, + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "Numm vum Ber\u00e4ich", + "mode": "Berechnung", + "show_on_map": "Op der Kaart uweisen" + }, + "title": "Netatmo Publique Wieder Sensor" + }, + "public_weather_areas": { + "data": { + "new_area": "Numm vum Ber\u00e4ich", + "weather_areas": "Wieder Ber\u00e4icher" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/no.json b/homeassistant/components/netatmo/translations/no.json index d3a4e111a95..a61c6576209 100644 --- a/homeassistant/components/netatmo/translations/no.json +++ b/homeassistant/components/netatmo/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_setup": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", "authorize_url_timeout": "Tidsavbrutt ved oppretting av godkjennings url.", "missing_configuration": "Komponeneten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen." }, diff --git a/homeassistant/components/netatmo/translations/pt.json b/homeassistant/components/netatmo/translations/pt.json index 8c857ebebbe..634d4810ca0 100644 --- a/homeassistant/components/netatmo/translations/pt.json +++ b/homeassistant/components/netatmo/translations/pt.json @@ -6,10 +6,26 @@ }, "options": { "step": { + "public_weather": { + "data": { + "area_name": "Nome da \u00e1rea", + "lat_ne": "Latitude nordeste", + "lat_sw": "Latitude sudoeste", + "lon_ne": "Longitude nordeste", + "lon_sw": "Longitude sudoeste", + "mode": "C\u00e1lculo", + "show_on_map": "Mostrar no mapa" + }, + "description": "Configurar um sensor de clima profundo para uma \u00e1rea.", + "title": "Sensor de clima Netatmo p\u00fablico" + }, "public_weather_areas": { "data": { - "new_area": "Nome da \u00e1rea" - } + "new_area": "Nome da \u00e1rea", + "weather_areas": "\u00c1reas de clima" + }, + "description": "Configurar sensores de clima p\u00fablicos.", + "title": "Sensor de clima Netatmo p\u00fablico" } } } diff --git a/homeassistant/components/netatmo/translations/sl.json b/homeassistant/components/netatmo/translations/sl.json index 7a617d5f866..16c74bc0ea7 100644 --- a/homeassistant/components/netatmo/translations/sl.json +++ b/homeassistant/components/netatmo/translations/sl.json @@ -13,5 +13,17 @@ "title": "Izberite medoto za preverjanje pristnosti" } } + }, + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "Ime obmo\u010dja" + } + }, + "public_weather_areas": { + "title": "Javni vremenski senzor Netatmo" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 9e5d33f5dbb..7126551883a 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -18,6 +18,11 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +EVENT_TYPE_MAP = { + "outdoor": "", + "therm_mode": "", +} + async def handle_webhook(hass, webhook_id, request): """Handle webhook callback.""" @@ -31,18 +36,13 @@ async def handle_webhook(hass, webhook_id, request): event_type = data.get(ATTR_EVENT_TYPE) - if event_type == "outdoor": + if event_type in ["outdoor", "therm_mode"]: hass.bus.async_fire( event_type=NETATMO_EVENT, event_data={"type": event_type, "data": data} ) - for event_data in data.get("event_list"): - async_evaluate_event(hass, event_data) - elif event_type == "therm_mode": - hass.bus.async_fire( - event_type=NETATMO_EVENT, event_data={"type": event_type, "data": data} - ) - for event_data in data.get("data"): + for event_data in data.get(EVENT_TYPE_MAP[event_type], []): async_evaluate_event(hass, event_data) + else: async_evaluate_event(hass, data) @@ -65,19 +65,8 @@ def async_evaluate_event(hass, event_data): event_type=NETATMO_EVENT, event_data={"type": event_type, "data": person_event_data}, ) - elif event_type == "therm_mode": - _LOGGER.debug("therm_mode: %s", event_data) - hass.bus.async_fire( - event_type=NETATMO_EVENT, - event_data={"type": event_type, "data": event_data}, - ) - elif event_type == "set_point": - _LOGGER.debug("set_point: %s", event_data) - hass.bus.async_fire( - event_type=NETATMO_EVENT, - event_data={"type": event_type, "data": event_data}, - ) else: + _LOGGER.debug("%s: %s", event_type, event_data) hass.bus.async_fire( event_type=NETATMO_EVENT, event_data={"type": event_type, "data": event_data}, diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index d815482c3f0..2898ee6ff64 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -2,6 +2,6 @@ "domain": "norway_air", "name": "Om Luftkvalitet i Norge (Norway Air)", "documentation": "https://www.home-assistant.io/integrations/norway_air", - "requirements": ["pyMetno==0.5.1"], + "requirements": ["pyMetno==0.7.0"], "codeowners": [] } diff --git a/homeassistant/components/notify/translations/es.json b/homeassistant/components/notify/translations/es.json index d92f73d4a77..edd2d8dc728 100644 --- a/homeassistant/components/notify/translations/es.json +++ b/homeassistant/components/notify/translations/es.json @@ -1,3 +1,3 @@ { - "title": "Notificar" + "title": "Notificaciones" } \ No newline at end of file diff --git a/homeassistant/components/notion/translations/es.json b/homeassistant/components/notion/translations/es.json index ca40862f475..2ea7fbb0db7 100644 --- a/homeassistant/components/notion/translations/es.json +++ b/homeassistant/components/notion/translations/es.json @@ -11,7 +11,7 @@ "user": { "data": { "password": "Contrase\u00f1a", - "username": "Usuario/correo electr\u00f3nico" + "username": "Usuario" }, "title": "Completa tu informaci\u00f3n" } diff --git a/homeassistant/components/notion/translations/tr.json b/homeassistant/components/notion/translations/tr.json new file mode 100644 index 00000000000..8966b79df1b --- /dev/null +++ b/homeassistant/components/notion/translations/tr.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "no_devices": "Hesapta cihaz bulunamad\u0131" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index ff61cb3cbb0..be8d3f62afa 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -4,7 +4,7 @@ import logging from numato_gpio import NumatoGpioError -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -63,7 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(binary_sensors, True) -class NumatoGpioBinarySensor(BinarySensorDevice): +class NumatoGpioBinarySensor(BinarySensorEntity): """Represents a binary sensor (input) port of a Numato GPIO expander.""" def __init__(self, name, device_id, port, invert_logic, api): diff --git a/homeassistant/components/numato/manifest.json b/homeassistant/components/numato/manifest.json index 8696151eecc..4b7dcd9e372 100644 --- a/homeassistant/components/numato/manifest.json +++ b/homeassistant/components/numato/manifest.json @@ -2,6 +2,6 @@ "domain": "numato", "name": "Numato USB GPIO Expander", "documentation": "https://www.home-assistant.io/integrations/numato", - "requirements": ["numato-gpio==0.7.1"], + "requirements": ["numato-gpio==0.8.0"], "codeowners": ["@clssn"] } diff --git a/homeassistant/components/nut/translations/no.json b/homeassistant/components/nut/translations/no.json index de43f9ead89..6fd749442c3 100644 --- a/homeassistant/components/nut/translations/no.json +++ b/homeassistant/components/nut/translations/no.json @@ -25,7 +25,7 @@ "data": { "host": "Vert", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn" }, "title": "Koble til NUT-serveren" diff --git a/homeassistant/components/nws/translations/es.json b/homeassistant/components/nws/translations/es.json index 0dd768d15d0..4c1107dd05e 100644 --- a/homeassistant/components/nws/translations/es.json +++ b/homeassistant/components/nws/translations/es.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "Clave API (correo electr\u00f3nico)", + "api_key": "Clave API", "latitude": "Latitud", "longitude": "Longitud", "station": "C\u00f3digo de estaci\u00f3n METAR" diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index bbcb2e4bc85..bb72a967605 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -2,6 +2,6 @@ "domain": "obihai", "name": "Obihai", "documentation": "https://www.home-assistant.io/integrations/obihai", - "requirements": ["pyobihai==1.2.1"], + "requirements": ["pyobihai==1.2.3"], "codeowners": ["@dshokouhi"] } diff --git a/homeassistant/components/onboarding/translations/tr.json b/homeassistant/components/onboarding/translations/tr.json new file mode 100644 index 00000000000..2fdec8c05ad --- /dev/null +++ b/homeassistant/components/onboarding/translations/tr.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Yatak odas\u0131", + "kitchen": "Mutfak", + "living_room": "Oturma odas\u0131" + } +} \ No newline at end of file diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index da33ff5f018..2b67f06ac3c 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -134,6 +134,8 @@ def determine_zones(receiver): if str(error) != TIMEOUT_MESSAGE: raise error _LOGGER.debug("Zone 3 timed out, assuming no functionality") + except AssertionError: + _LOGGER.error("Zone 3 detection failed") return out diff --git a/homeassistant/components/onvif/translations/no.json b/homeassistant/components/onvif/translations/no.json index 5f55b264117..4f605a518d7 100644 --- a/homeassistant/components/onvif/translations/no.json +++ b/homeassistant/components/onvif/translations/no.json @@ -35,7 +35,7 @@ "data": { "host": "Vert", "name": "Navn", - "port": "Port" + "port": "" }, "title": "Konfigurere ONVIF-enhet" }, diff --git a/homeassistant/components/onvif/translations/tr.json b/homeassistant/components/onvif/translations/tr.json new file mode 100644 index 00000000000..fc82ed5bb8a --- /dev/null +++ b/homeassistant/components/onvif/translations/tr.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "onvif_devices": { + "data": { + "extra_arguments": "Ekstra FFMPEG arg\u00fcmanlar", + "rtsp_transport": "RTSP ta\u015f\u0131ma mekanizmas\u0131" + }, + "title": "ONVIF Cihaz Se\u00e7enekleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index ed8fd9c662c..1fb7096d5fa 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,6 +2,6 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.19.0", "opencv-python-headless==4.2.0.32"], + "requirements": ["numpy==1.19.1", "opencv-python-headless==4.3.0.36"], "codeowners": [] } diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 71fd104bd2f..8d1d3ae4d62 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -29,6 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( + ATTR_CH_OVRD, ATTR_DHW_OVRD, ATTR_GW_ID, ATTR_LEVEL, @@ -39,6 +40,7 @@ from .const import ( DATA_OPENTHERM_GW, DOMAIN, SERVICE_RESET_GATEWAY, + SERVICE_SET_CH_OVRD, SERVICE_SET_CLOCK, SERVICE_SET_CONTROL_SETPOINT, SERVICE_SET_GPIO_MODE, @@ -127,6 +129,14 @@ def register_services(hass): ) } ) + service_set_central_heating_ovrd_schema = vol.Schema( + { + vol.Required(ATTR_GW_ID): vol.All( + cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS]) + ), + vol.Required(ATTR_CH_OVRD): cv.boolean, + } + ) service_set_clock_schema = vol.Schema( { vol.Required(ATTR_GW_ID): vol.All( @@ -235,6 +245,18 @@ def register_services(hass): DOMAIN, SERVICE_RESET_GATEWAY, reset_gateway, service_reset_schema ) + async def set_ch_ovrd(call): + """Set the central heating override on the OpenTherm Gateway.""" + gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] + await gw_dev.gateway.set_ch_enable_bit(1 if call.data[ATTR_CH_OVRD] else 0) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_CH_OVRD, + set_ch_ovrd, + service_set_central_heating_ovrd_schema, + ) + async def set_control_setpoint(call): """Set the control setpoint on the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 14b54366b4a..5be29522535 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ATTR_GW_ID = "gateway_id" ATTR_LEVEL = "level" ATTR_DHW_OVRD = "dhw_override" +ATTR_CH_OVRD = "ch_override" CONF_CLIMATE = "climate" CONF_FLOOR_TEMP = "floor_temperature" @@ -27,6 +28,7 @@ DEVICE_CLASS_PROBLEM = "problem" DOMAIN = "opentherm_gw" SERVICE_RESET_GATEWAY = "reset_gateway" +SERVICE_SET_CH_OVRD = "set_central_heating_ovrd" SERVICE_SET_CLOCK = "set_clock" SERVICE_SET_CONTROL_SETPOINT = "set_control_setpoint" SERVICE_SET_HOT_WATER_SETPOINT = "set_hot_water_setpoint" diff --git a/homeassistant/components/opentherm_gw/services.yaml b/homeassistant/components/opentherm_gw/services.yaml index f60648ee8d4..8a1bddc2100 100644 --- a/homeassistant/components/opentherm_gw/services.yaml +++ b/homeassistant/components/opentherm_gw/services.yaml @@ -7,6 +7,22 @@ reset_gateway: description: The gateway_id of the OpenTherm Gateway. example: "opentherm_gateway" +set_central_heating_ovrd: + description: > + Set the central heating override option on the gateway. + When overriding the control setpoint (via a set_control_setpoint service call with a value other than 0), the gateway automatically enables the central heating override to start heating. + This service can then be used to control the central heating override status. + To return control of the central heating to the thermostat, call the set_control_setpoint service with temperature value 0. + You will only need this if you are writing your own software thermostat. + fields: + gateway_id: + description: The gateway_id of the OpenTherm Gateway. + example: "opentherm_gateway" + ch_override: + description: > + The desired boolean value for the central heating override. + example: "on" + set_clock: description: Set the clock and day of the week on the connected thermostat. fields: diff --git a/homeassistant/components/openuv/translations/es.json b/homeassistant/components/openuv/translations/es.json index 4eb27857310..45e566ac2af 100644 --- a/homeassistant/components/openuv/translations/es.json +++ b/homeassistant/components/openuv/translations/es.json @@ -5,12 +5,12 @@ }, "error": { "identifier_exists": "Coordenadas ya registradas", - "invalid_api_key": "Clave API inv\u00e1lida" + "invalid_api_key": "Clave API no v\u00e1lida" }, "step": { "user": { "data": { - "api_key": "Clave API de OpenUV", + "api_key": "Clave API", "elevation": "Elevaci\u00f3n", "latitude": "Latitud", "longitude": "Longitud" diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py new file mode 100644 index 00000000000..3aff51fa044 --- /dev/null +++ b/homeassistant/components/ovo_energy/__init__.py @@ -0,0 +1,148 @@ +"""Support for OVO Energy.""" +from datetime import datetime, timedelta +import logging +from typing import Any, Dict + +import aiohttp +import async_timeout +from ovoenergy import OVODailyUsage +from ovoenergy.ovoenergy import OVOEnergy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the OVO Energy components.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up OVO Energy from a config entry.""" + + client = OVOEnergy() + + try: + await client.authenticate(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise ConfigEntryNotReady from exception + + async def async_update_data() -> OVODailyUsage: + """Fetch data from OVO Energy.""" + now = datetime.utcnow() + async with async_timeout.timeout(10): + return await client.get_daily_usage(now.strftime("%Y-%m")) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="sensor", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=300), + ) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: client, + DATA_COORDINATOR: coordinator, + } + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + # Setup components + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: + """Unload OVO Energy config entry.""" + # Unload sensors + await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + del hass.data[DOMAIN][entry.entry_id] + + return True + + +class OVOEnergyEntity(Entity): + """Defines a base OVO Energy entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + client: OVOEnergy, + key: str, + name: str, + icon: str, + ) -> None: + """Initialize the OVO Energy entity.""" + self._coordinator = 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 + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + async def async_update(self) -> None: + """Update OVO Energy entity.""" + await self._coordinator.async_request_refresh() + + async def async_added_to_hass(self) -> None: + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + +class OVOEnergyDeviceEntity(OVOEnergyEntity): + """Defines a OVO Energy device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this OVO Energy instance.""" + return { + "identifiers": {(DOMAIN, self._client.account_id)}, + "manufacturer": "OVO Energy", + "name": self._client.account_id, + "entry_type": "service", + } diff --git a/homeassistant/components/ovo_energy/config_flow.py b/homeassistant/components/ovo_energy/config_flow.py new file mode 100644 index 00000000000..ac3e8371123 --- /dev/null +++ b/homeassistant/components/ovo_energy/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow to configure the OVO Energy integration.""" +import logging + +import aiohttp +from ovoenergy.ovoenergy import OVOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CONF_ACCOUNT_ID, DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class OVOEnergyFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a OVO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + if user_input is not None: + client = OVOEnergy() + try: + authenticated = await client.authenticate( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + except aiohttp.ClientError: + errors["base"] = "connection_error" + else: + if authenticated: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=client.account_id, + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_ACCOUNT_ID: client.account_id, + }, + ) + + errors["base"] = "authorization_error" + + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ovo_energy/const.py b/homeassistant/components/ovo_energy/const.py new file mode 100644 index 00000000000..e836bb2ca8a --- /dev/null +++ b/homeassistant/components/ovo_energy/const.py @@ -0,0 +1,7 @@ +"""Constants for the OVO Energy integration.""" +DOMAIN = "ovo_energy" + +DATA_CLIENT = "ovo_client" +DATA_COORDINATOR = "coordinator" + +CONF_ACCOUNT_ID = "account_id" diff --git a/homeassistant/components/ovo_energy/manifest.json b/homeassistant/components/ovo_energy/manifest.json new file mode 100644 index 00000000000..2da08d3339b --- /dev/null +++ b/homeassistant/components/ovo_energy/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ovo_energy", + "name": "OVO Energy", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ovo_energy", + "requirements": ["ovoenergy==1.1.6"], + "codeowners": ["@timmo001"] +} diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py new file mode 100644 index 00000000000..5fe1bb056e7 --- /dev/null +++ b/homeassistant/components/ovo_energy/sensor.py @@ -0,0 +1,207 @@ +"""Support for OVO Energy sensors.""" +from datetime import timedelta +import logging + +from ovoenergy import OVODailyUsage +from ovoenergy.ovoenergy import OVOEnergy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import OVOEnergyDeviceEntity +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up OVO Energy sensor based on a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + client: OVOEnergy = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + + currency = coordinator.data.electricity[ + len(coordinator.data.electricity) - 1 + ].cost.currency_unit + + async_add_entities( + [ + OVOEnergyLastElectricityReading(coordinator, client), + OVOEnergyLastGasReading(coordinator, client), + OVOEnergyLastElectricityCost(coordinator, client, currency), + OVOEnergyLastGasCost(coordinator, client, currency), + ], + True, + ) + + +class OVOEnergySensor(OVOEnergyDeviceEntity): + """Defines a OVO Energy sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + 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): + """Initialize OVO Energy sensor.""" + + super().__init__( + coordinator, + client, + f"{client.account_id}_last_electricity_reading", + "OVO Last Electricity Reading", + "mdi:flash", + "kWh", + ) + + @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].consumption + + @property + def device_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): + """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 device_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 + ): + """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 device_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 + ): + """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 device_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, + } diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json new file mode 100644 index 00000000000..0132f3582b6 --- /dev/null +++ b/homeassistant/components/ovo_energy/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "authorization_error": "Authorization error. Check your credentials.", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Set up an OVO Energy instance to access your energy usage.", + "title": "Add OVO Energy Account" + } + } + } +} diff --git a/homeassistant/components/ovo_energy/translations/en.json b/homeassistant/components/ovo_energy/translations/en.json new file mode 100644 index 00000000000..0132f3582b6 --- /dev/null +++ b/homeassistant/components/ovo_energy/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "authorization_error": "Authorization error. Check your credentials.", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Set up an OVO Energy instance to access your energy usage.", + "title": "Add OVO Energy Account" + } + } + } +} diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index e7f6e0d3587..bb48c180971 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -35,6 +35,7 @@ from .entity import ( create_value_id, ) from .services import ZWaveServices +from .websocket_api import ZWaveWebsocketApi _LOGGER = logging.getLogger(__name__) @@ -113,7 +114,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Filter out CommandClasses we're definitely not interested in. if value.command_class in [ - CommandClass.CONFIGURATION, CommandClass.VERSION, CommandClass.MANUFACTURER_SPECIFIC, ]: @@ -206,6 +206,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): services = ZWaveServices(hass, manager) services.async_register() + # Register WebSocket API + ws_api = ZWaveWebsocketApi(hass, manager) + ws_api.async_register_api() + @callback def async_receive_message(msg): manager.receive_message(msg.topic, msg.payload) diff --git a/homeassistant/components/ozw/discovery.py b/homeassistant/components/ozw/discovery.py index 12690b343fc..84b1d4180e1 100644 --- a/homeassistant/components/ozw/discovery.py +++ b/homeassistant/components/ozw/discovery.py @@ -225,6 +225,8 @@ DISCOVERY_SCHEMAS = ( const.DISC_SPECIFIC_DEVICE_CLASS: ( const_ozw.SPECIFIC_TYPE_POWER_SWITCH_MULTILEVEL, const_ozw.SPECIFIC_TYPE_SCENE_SWITCH_MULTILEVEL, + const_ozw.SPECIFIC_TYPE_COLOR_TUNABLE_BINARY, + const_ozw.SPECIFIC_TYPE_COLOR_TUNABLE_MULTILEVEL, const_ozw.SPECIFIC_TYPE_NOT_USED, ), const.DISC_VALUES: { @@ -248,6 +250,16 @@ DISCOVERY_SCHEMAS = ( const.DISC_INDEX: ValueIndex.SWITCH_COLOR_CHANNELS, const.DISC_OPTIONAL: True, }, + "min_kelvin": { + const.DISC_COMMAND_CLASS: (CommandClass.CONFIGURATION,), + const.DISC_INDEX: 81, # PR for upstream to add SWITCH_COLOR_CT_WARM + const.DISC_OPTIONAL: True, + }, + "max_kelvin": { + const.DISC_COMMAND_CLASS: (CommandClass.CONFIGURATION,), + const.DISC_INDEX: 82, # PR for upstream to add SWITCH_COLOR_CT_COLD + const.DISC_OPTIONAL: True, + }, }, }, { # All other text/numeric sensors diff --git a/homeassistant/components/ozw/entity.py b/homeassistant/components/ozw/entity.py index d64beb0ba34..deb70af1bb5 100644 --- a/homeassistant/components/ozw/entity.py +++ b/homeassistant/components/ozw/entity.py @@ -247,6 +247,11 @@ class ZWaveDeviceEntity(Entity): self.on_value_update() self.async_write_ha_state() + @property + def should_poll(self): + """No polling needed.""" + return False + async def _delete_callback(self, values_id): """Remove this entity.""" if not self.values: diff --git a/homeassistant/components/ozw/light.py b/homeassistant/components/ozw/light.py index 640f675612c..2c7461976c4 100644 --- a/homeassistant/components/ozw/light.py +++ b/homeassistant/components/ozw/light.py @@ -3,20 +3,34 @@ import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, ATTR_TRANSITION, + ATTR_WHITE_VALUE, DOMAIN as LIGHT_DOMAIN, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, + SUPPORT_WHITE_VALUE, LightEntity, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.color as color_util from .const import DATA_UNSUBSCRIBE, DOMAIN from .entity import ZWaveDeviceEntity _LOGGER = logging.getLogger(__name__) +ATTR_VALUE = "Value" +COLOR_CHANNEL_WARM_WHITE = 0x01 +COLOR_CHANNEL_COLD_WHITE = 0x02 +COLOR_CHANNEL_RED = 0x04 +COLOR_CHANNEL_GREEN = 0x08 +COLOR_CHANNEL_BLUE = 0x10 + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Z-Wave Light from Config Entry.""" @@ -24,7 +38,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_light(values): """Add Z-Wave Light.""" - light = ZwaveDimmer(values) + light = ZwaveLight(values) + async_add_entities([light]) hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( @@ -42,23 +57,48 @@ def byte_to_zwave_brightness(value): return 0 -class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): - """Representation of a Z-Wave dimmer.""" +class ZwaveLight(ZWaveDeviceEntity, LightEntity): + """Representation of a Z-Wave light.""" def __init__(self, values): """Initialize the light.""" super().__init__(values) + self._color_channels = None + self._hs = None + self._white = None + self._ct = None self._supported_features = SUPPORT_BRIGHTNESS + self._min_mireds = 153 # 6500K as a safe default + self._max_mireds = 370 # 2700K as a safe default + # make sure that supported features is correctly set self.on_value_update() @callback def on_value_update(self): """Call when the underlying value(s) is added or updated.""" - self._supported_features = SUPPORT_BRIGHTNESS if self.values.dimming_duration is not None: self._supported_features |= SUPPORT_TRANSITION + if self.values.color is None or self.values.color_channels is None: + return + + self._supported_features |= SUPPORT_COLOR + + # Support Color Temp if both white channels + if (self.values.color_channels.value & COLOR_CHANNEL_WARM_WHITE) and ( + self.values.color_channels.value & COLOR_CHANNEL_COLD_WHITE + ): + self._supported_features |= SUPPORT_COLOR_TEMP + + # Support White value if only a single white channel + if ((self.values.color_channels.value & COLOR_CHANNEL_WARM_WHITE) != 0) ^ ( + (self.values.color_channels.value & COLOR_CHANNEL_COLD_WHITE) != 0 + ): + self._supported_features |= SUPPORT_WHITE_VALUE + + self._calculate_rgb_values() + @property def brightness(self): """Return the brightness of this light between 0..255. @@ -81,33 +121,70 @@ class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): """Flag supported features.""" return self._supported_features + @property + def hs_color(self): + """Return the hs color.""" + return self._hs + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._white + + @property + def color_temp(self): + """Return the color temperature.""" + return self._ct + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return self._min_mireds + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return self._max_mireds + @callback def async_set_duration(self, **kwargs): """Set the transition time for the brightness value. - Zwave Dimming Duration values: - 0 = instant - 0-127 = 1 second to 127 seconds - 128-254 = 1 minute to 127 minutes - 255 = factory default + Zwave Dimming Duration values now use seconds as an + integer (max: 7620 seconds or 127 mins) + Build 1205 https://github.com/OpenZWave/open-zwave/commit/f81bc04 """ if self.values.dimming_duration is None: return + ozw_version = tuple( + int(x) + for x in self.values.primary.ozw_instance.get_status().openzwave_version.split( + "." + ) + ) + if ATTR_TRANSITION not in kwargs: # no transition specified by user, use defaults - new_value = 255 + new_value = 7621 # anything over 7620 uses the factory default + if ozw_version < (1, 6, 1205): + new_value = 255 # default for older version + else: - # transition specified by user, convert to zwave value - transition = kwargs[ATTR_TRANSITION] - if transition <= 127: - new_value = int(transition) - else: - minutes = int(transition / 60) - _LOGGER.debug( - "Transition rounded to %d minutes for %s", minutes, self.entity_id - ) - new_value = minutes + 128 + # transition specified by user + new_value = max(0, min(7620, kwargs[ATTR_TRANSITION])) + if ozw_version < (1, 6, 1205): + transition = kwargs[ATTR_TRANSITION] + if transition <= 127: + new_value = int(transition) + else: + minutes = int(transition / 60) + _LOGGER.debug( + "Transition rounded to %d minutes for %s", + minutes, + self.entity_id, + ) + new_value = minutes + 128 # only send value if it differs from current # this prevents a command for nothing @@ -118,6 +195,43 @@ class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): """Turn the device on.""" self.async_set_duration(**kwargs) + rgbw = None + white = kwargs.get(ATTR_WHITE_VALUE) + hs_color = kwargs.get(ATTR_HS_COLOR) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + + if hs_color is not None: + rgbw = "#" + for colorval in color_util.color_hs_to_RGB(*hs_color): + rgbw += f"{colorval:02x}" + rgbw += "0000" + # white LED must be off in order for color to work + + elif white is not None: + if self._color_channels & COLOR_CHANNEL_WARM_WHITE: + rgbw = f"#000000{white:02x}00" + else: + rgbw = f"#00000000{white:02x}" + + elif color_temp is not None: + # Limit color temp to min/max values + cold = max( + 0, + min( + 255, + round( + (self._max_mireds - color_temp) + / (self._max_mireds - self._min_mireds) + * 255 + ), + ), + ) + warm = 255 - cold + rgbw = f"#000000{warm:02x}{cold:02x}" + + if rgbw and self.values.color: + self.values.color.send_value(rgbw) + # Zwave multilevel switches use a range of [0, 99] to control # brightness. Level 255 means to set it to previous value. if ATTR_BRIGHTNESS in kwargs: @@ -133,3 +247,56 @@ class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): self.async_set_duration(**kwargs) self.values.primary.send_value(0) + + def _calculate_rgb_values(self): + # Color Channels + self._color_channels = self.values.color_channels.data[ATTR_VALUE] + + # Color Data String + data = self.values.color.data[ATTR_VALUE] + + # RGB is always present in the OpenZWave color data string. + rgb = [int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16)] + self._hs = color_util.color_RGB_to_hs(*rgb) + + # Parse remaining color channels. OpenZWave appends white channels + # that are present. + index = 7 + temp_warm = 0 + temp_cold = 0 + + # Update color temp limits. + if self.values.min_kelvin: + self._max_mireds = color_util.color_temperature_kelvin_to_mired( + self.values.min_kelvin.data[ATTR_VALUE] + ) + if self.values.max_kelvin: + self._min_mireds = color_util.color_temperature_kelvin_to_mired( + self.values.max_kelvin.data[ATTR_VALUE] + ) + + # Warm white + if self._color_channels & COLOR_CHANNEL_WARM_WHITE: + self._white = int(data[index : index + 2], 16) + temp_warm = self._white + + index += 2 + + # Cold white + if self._color_channels & COLOR_CHANNEL_COLD_WHITE: + self._white = int(data[index : index + 2], 16) + temp_cold = self._white + + # Calculate color temps based on white LED status + if temp_cold or temp_warm: + self._ct = round( + self._max_mireds + - ((temp_cold / 255) * (self._max_mireds - self._min_mireds)) + ) + + if not ( + self._color_channels & COLOR_CHANNEL_RED + or self._color_channels & COLOR_CHANNEL_GREEN + or self._color_channels & COLOR_CHANNEL_BLUE + ): + self._hs = None diff --git a/homeassistant/components/ozw/sensor.py b/homeassistant/components/ozw/sensor.py index 309c2784405..453015991b7 100644 --- a/homeassistant/components/ozw/sensor.py +++ b/homeassistant/components/ozw/sensor.py @@ -87,6 +87,11 @@ class ZwaveSensorBase(ZWaveDeviceEntity): return False return True + @property + def force_update(self) -> bool: + """Force updates.""" + return True + class ZWaveNumericSensor(ZwaveSensorBase): """Representation of a Z-Wave sensor.""" diff --git a/homeassistant/components/ozw/websocket_api.py b/homeassistant/components/ozw/websocket_api.py new file mode 100644 index 00000000000..e7c8b047f84 --- /dev/null +++ b/homeassistant/components/ozw/websocket_api.py @@ -0,0 +1,115 @@ +"""Web socket API for OpenZWave.""" + +import logging + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + +TYPE = "type" +ID = "id" +OZW_INSTANCE = "ozw_instance" +NODE_ID = "node_id" + + +class ZWaveWebsocketApi: + """Class that holds our websocket api commands.""" + + def __init__(self, hass, manager): + """Initialize with both hass and ozwmanager objects.""" + self._hass = hass + self._manager = manager + + @callback + def async_register_api(self): + """Register all of our api endpoints.""" + websocket_api.async_register_command(self._hass, self.websocket_network_status) + websocket_api.async_register_command(self._hass, self.websocket_node_status) + websocket_api.async_register_command(self._hass, self.websocket_node_statistics) + + @websocket_api.websocket_command( + { + vol.Required(TYPE): "ozw/network_status", + vol.Optional(OZW_INSTANCE, default=1): vol.Coerce(int), + } + ) + def websocket_network_status(self, hass, connection, msg): + """Get Z-Wave network status.""" + + connection.send_result( + msg[ID], + { + "state": self._manager.get_instance(msg[OZW_INSTANCE]) + .get_status() + .status, + OZW_INSTANCE: msg[OZW_INSTANCE], + }, + ) + + @websocket_api.websocket_command( + { + vol.Required(TYPE): "ozw/node_status", + vol.Required(NODE_ID): vol.Coerce(int), + vol.Optional(OZW_INSTANCE, default=1): vol.Coerce(int), + } + ) + def websocket_node_status(self, hass, connection, msg): + """Get the status for a Z-Wave node.""" + + node = self._manager.get_instance(msg[OZW_INSTANCE]).get_node(msg[NODE_ID]) + connection.send_result( + msg[ID], + { + "node_query_stage": node.node_query_stage, + "node_id": node.node_id, + "is_zwave_plus": node.is_zwave_plus, + "is_awake": node.is_awake, + "is_failed": node.is_failed, + "node_baud_rate": node.node_baud_rate, + "is_beaming": node.is_beaming, + "is_flirs": node.is_flirs, + "is_routing": node.is_routing, + "is_securityv1": node.is_securityv1, + "node_basic_string": node.node_basic_string, + "node_generic_string": node.node_generic_string, + "node_specific_string": node.node_specific_string, + "neighbors": node.neighbors, + OZW_INSTANCE: msg[OZW_INSTANCE], + }, + ) + + @websocket_api.websocket_command( + { + vol.Required(TYPE): "ozw/node_statistics", + vol.Required(NODE_ID): vol.Coerce(int), + vol.Optional(OZW_INSTANCE, default=1): vol.Coerce(int), + } + ) + def websocket_node_statistics(self, hass, connection, msg): + """Get the statistics for a Z-Wave node.""" + + stats = ( + self._manager.get_instance(msg[OZW_INSTANCE]) + .get_node(msg[NODE_ID]) + .get_statistics() + ) + connection.send_result( + msg[ID], + { + "node_id": msg[NODE_ID], + "send_count": stats.send_count, + "sent_failed": stats.sent_failed, + "retries": stats.retries, + "last_request_rtt": stats.last_request_rtt, + "last_response_rtt": stats.last_response_rtt, + "average_request_rtt": stats.average_request_rtt, + "average_response_rtt": stats.average_response_rtt, + "received_packets": stats.received_packets, + "received_dup_packets": stats.received_dup_packets, + "received_unsolicited": stats.received_unsolicited, + OZW_INSTANCE: msg[OZW_INSTANCE], + }, + ) diff --git a/homeassistant/components/person/translations/es.json b/homeassistant/components/person/translations/es.json index c87164c5f12..98fca470569 100644 --- a/homeassistant/components/person/translations/es.json +++ b/homeassistant/components/person/translations/es.json @@ -1,7 +1,7 @@ { "state": { "_": { - "home": "Casa", + "home": "En casa", "not_home": "Fuera de casa" } }, diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index eba9053183b..9b51cc09b35 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -1,11 +1,11 @@ """The pi_hole component.""" +import asyncio import logging from hole import Hole from hole.exceptions import HoleError import voluptuous as vol -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_API_KEY, @@ -14,9 +14,11 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) +from homeassistant.core import callback 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.entity import Entity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -29,11 +31,6 @@ from .const import ( DEFAULT_VERIFY_SSL, DOMAIN, MIN_TIME_BETWEEN_UPDATES, - SERVICE_DISABLE, - SERVICE_DISABLE_ATTR_DURATION, - SERVICE_DISABLE_ATTR_NAME, - SERVICE_ENABLE, - SERVICE_ENABLE_ATTR_NAME, ) _LOGGER = logging.getLogger(__name__) @@ -58,20 +55,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): - """Set up the Pi_hole integration.""" - - service_disable_schema = vol.Schema( - vol.All( - { - vol.Required(SERVICE_DISABLE_ATTR_DURATION): vol.All( - cv.time_period_str, cv.positive_timedelta - ), - vol.Optional(SERVICE_DISABLE_ATTR_NAME): str, - }, - ) - ) - - service_enable_schema = vol.Schema({vol.Optional(SERVICE_ENABLE_ATTR_NAME): str}) + """Set up the Pi-hole integration.""" hass.data[DOMAIN] = {} @@ -84,71 +68,6 @@ async def async_setup(hass, config): ) ) - def get_api_from_name(name): - """Get Pi-hole API object from user configured name.""" - hole_data = hass.data[DOMAIN].get(name) - if hole_data is None: - _LOGGER.error("Unknown Pi-hole name %s", name) - return None - api = hole_data[DATA_KEY_API] - if not api.api_token: - _LOGGER.error( - "Pi-hole %s must have an api_key provided in configuration to be enabled", - name, - ) - return None - return api - - async def disable_service_handler(call): - """Handle the service call to disable a single Pi-hole or all configured Pi-holes.""" - duration = call.data[SERVICE_DISABLE_ATTR_DURATION].total_seconds() - name = call.data.get(SERVICE_DISABLE_ATTR_NAME) - - async def do_disable(name): - """Disable the named Pi-hole.""" - api = get_api_from_name(name) - if api is None: - return - - _LOGGER.debug( - "Disabling Pi-hole '%s' (%s) for %d seconds", name, api.host, duration, - ) - await api.disable(duration) - - if name is not None: - await do_disable(name) - else: - for name in hass.data[DOMAIN]: - await do_disable(name) - - async def enable_service_handler(call): - """Handle the service call to enable a single Pi-hole or all configured Pi-holes.""" - - name = call.data.get(SERVICE_ENABLE_ATTR_NAME) - - async def do_enable(name): - """Enable the named Pi-hole.""" - api = get_api_from_name(name) - if api is None: - return - - _LOGGER.debug("Enabling Pi-hole '%s' (%s)", name, api.host) - await api.enable() - - if name is not None: - await do_enable(name) - else: - for name in hass.data[DOMAIN]: - await do_enable(name) - - hass.services.async_register( - DOMAIN, SERVICE_DISABLE, disable_service_handler, schema=service_disable_schema - ) - - hass.services.async_register( - DOMAIN, SERVICE_ENABLE, enable_service_handler, schema=service_enable_schema - ) - return True @@ -187,19 +106,85 @@ async def async_setup_entry(hass, entry): update_method=async_update_data, update_interval=MIN_TIME_BETWEEN_UPDATES, ) - hass.data[DOMAIN][name] = { + hass.data[DOMAIN][entry.entry_id] = { DATA_KEY_API: api, DATA_KEY_COORDINATOR: coordinator, } - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN) - ) + for platform in _async_platforms(entry): + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) return True async def async_unload_entry(hass, entry): - """Unload pi-hole entry.""" - hass.data[DOMAIN].pop(entry.data[CONF_NAME]) - return await hass.config_entries.async_forward_entry_unload(entry, SENSOR_DOMAIN) + """Unload Pi-hole entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in _async_platforms(entry) + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +@callback +def _async_platforms(entry): + """Return platforms to be loaded / unloaded.""" + platforms = ["sensor"] + if entry.data.get(CONF_API_KEY): + platforms.append("switch") + else: + platforms.append("binary_sensor") + return platforms + + +class PiHoleEntity(Entity): + """Representation of a Pi-hole entity.""" + + def __init__(self, api, coordinator, name, server_unique_id): + """Initialize a Pi-hole entity.""" + self.api = api + self.coordinator = coordinator + self._name = name + self._server_unique_id = server_unique_id + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return "mdi:pi-hole" + + @property + def device_info(self): + """Return the device information of the entity.""" + return { + "identifiers": {(DOMAIN, self._server_unique_id)}, + "name": self._name, + "manufacturer": "Pi-hole", + } + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self.coordinator.last_update_success + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + async def async_update(self): + """Get the latest data from the Pi-hole API.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py new file mode 100644 index 00000000000..d572bb390e5 --- /dev/null +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -0,0 +1,44 @@ +"""Support for getting status from a Pi-hole system.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import CONF_NAME + +from . import PiHoleEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Pi-hole binary sensor.""" + name = entry.data[CONF_NAME] + hole_data = hass.data[PIHOLE_DOMAIN][entry.entry_id] + binary_sensors = [ + PiHoleBinarySensor( + hole_data[DATA_KEY_API], + hole_data[DATA_KEY_COORDINATOR], + name, + entry.entry_id, + ) + ] + async_add_entities(binary_sensors, True) + + +class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): + """Representation of a Pi-hole binary sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return f"{self._server_unique_id}/Status" + + @property + def is_on(self): + """Return if the service is on.""" + return self.api.data.get("status") == "enabled" diff --git a/homeassistant/components/pi_hole/config_flow.py b/homeassistant/components/pi_hole/config_flow.py index 2b0ebfb7c16..c7061b05caa 100644 --- a/homeassistant/components/pi_hole/config_flow.py +++ b/homeassistant/components/pi_hole/config_flow.py @@ -60,10 +60,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if await self._async_endpoint_existed(endpoint): return self.async_abort(reason="already_configured") - if await self._async_name_existed(name): - if is_import: - _LOGGER.error("Failed to import: name %s already existed", name) - return self.async_abort(reason="duplicated_name") try: await self._async_try_connect( @@ -127,12 +123,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ] return endpoint in existing_endpoints - async def _async_name_existed(self, name): - existing_names = [ - entry.data.get(CONF_NAME) for entry in self._async_current_entries() - ] - return name in existing_names - async def _async_try_connect(self, host, location, tls, verify_tls, api_token): session = async_get_clientsession(self.hass, verify_tls) pi_hole = Hole( diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index a5807de5575..cb8087fdbf0 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -15,9 +15,6 @@ DEFAULT_VERIFY_SSL = True SERVICE_DISABLE = "disable" SERVICE_DISABLE_ATTR_DURATION = "duration" -SERVICE_DISABLE_ATTR_NAME = "name" -SERVICE_ENABLE = "enable" -SERVICE_ENABLE_ATTR_NAME = SERVICE_DISABLE_ATTR_NAME ATTR_BLOCKED_DOMAINS = "domains_blocked" diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index d0009f1ebba..179e61a21cc 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -2,8 +2,8 @@ import logging from homeassistant.const import CONF_NAME -from homeassistant.helpers.entity import Entity +from . import PiHoleEntity from .const import ( ATTR_BLOCKED_DOMAINS, DATA_KEY_API, @@ -19,7 +19,7 @@ LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up the Pi-hole sensor.""" name = entry.data[CONF_NAME] - hole_data = hass.data[PIHOLE_DOMAIN][name] + hole_data = hass.data[PIHOLE_DOMAIN][entry.entry_id] sensors = [ PiHoleSensor( hole_data[DATA_KEY_API], @@ -33,28 +33,20 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(sensors, True) -class PiHoleSensor(Entity): +class PiHoleSensor(PiHoleEntity): """Representation of a Pi-hole sensor.""" def __init__(self, api, coordinator, name, sensor_name, server_unique_id): """Initialize a Pi-hole sensor.""" - self.api = api - self.coordinator = coordinator - self._name = name + super().__init__(api, coordinator, name, server_unique_id) + self._condition = sensor_name - self._server_unique_id = server_unique_id variable_info = SENSOR_DICT[sensor_name] self._condition_name = variable_info[0] self._unit_of_measurement = variable_info[1] self._icon = variable_info[2] - async def async_added_to_hass(self): - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - @property def name(self): """Return the name of the sensor.""" @@ -65,15 +57,6 @@ class PiHoleSensor(Entity): """Return the unique id of the sensor.""" return f"{self._server_unique_id}/{self._condition_name}" - @property - def device_info(self): - """Return the device information of the sensor.""" - return { - "identifiers": {(PIHOLE_DOMAIN, self._server_unique_id)}, - "name": self._name, - "manufacturer": "Pi-hole", - } - @property def icon(self): """Icon to use in the frontend, if any.""" @@ -96,17 +79,3 @@ class PiHoleSensor(Entity): def device_state_attributes(self): """Return the state attributes of the Pi-hole.""" return {ATTR_BLOCKED_DOMAINS: self.api.data["domains_being_blocked"]} - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self.coordinator.last_update_success - - @property - def should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" - return False - - async def async_update(self): - """Get the latest data from the Pi-hole API.""" - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/pi_hole/services.yaml b/homeassistant/components/pi_hole/services.yaml index 9bb31b1723f..fb9a5c17a13 100644 --- a/homeassistant/components/pi_hole/services.yaml +++ b/homeassistant/components/pi_hole/services.yaml @@ -1,15 +1,9 @@ disable: description: Disable configured Pi-hole(s) for an amount of time fields: + entity_id: + description: Target switch entity + example: switch.pi_hole duration: description: Time that the Pi-hole should be disabled for example: "00:00:15" - name: - description: "[Optional] When multiple Pi-holes are configured, the name of the one to disable. If omitted, all configured Pi-holes will be disabled." - example: "Pi-Hole" -enable: - description: Enable configured Pi-hole(s) - fields: - name: - description: "[Optional] When multiple Pi-holes are configured, the name of the one to enable. If omitted, all configured Pi-holes will be enabled." - example: "Pi-Hole" diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index b155550844a..42faf5d5a46 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -6,7 +6,8 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "name": "Name", - "api_key": "API Key (Optional)", + "location": "Location", + "api_key": "[%key:common::config_flow::data::api_key%]", "ssl": "Use SSL", "verify_ssl": "Verify SSL certificate" } @@ -16,8 +17,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "duplicated_name": "Name already existed" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } } } diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py new file mode 100644 index 00000000000..015bab8fe60 --- /dev/null +++ b/homeassistant/components/pi_hole/switch.py @@ -0,0 +1,100 @@ +"""Support for turning on and off Pi-hole system.""" +import logging + +from hole.exceptions import HoleError +import voluptuous as vol + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv, entity_platform + +from . import PiHoleEntity +from .const import ( + DATA_KEY_API, + DATA_KEY_COORDINATOR, + DOMAIN as PIHOLE_DOMAIN, + SERVICE_DISABLE, + SERVICE_DISABLE_ATTR_DURATION, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Pi-hole switch.""" + name = entry.data[CONF_NAME] + hole_data = hass.data[PIHOLE_DOMAIN][entry.entry_id] + switches = [ + PiHoleSwitch( + hole_data[DATA_KEY_API], + hole_data[DATA_KEY_COORDINATOR], + name, + entry.entry_id, + ) + ] + async_add_entities(switches, True) + + # register service + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_DISABLE, + { + vol.Required(SERVICE_DISABLE_ATTR_DURATION): vol.All( + cv.time_period_str, cv.positive_timedelta + ), + }, + "async_disable", + ) + + +class PiHoleSwitch(PiHoleEntity, SwitchEntity): + """Representation of a Pi-hole switch.""" + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def unique_id(self): + """Return the unique id of the switch.""" + return f"{self._server_unique_id}/Switch" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return "mdi:pi-hole" + + @property + def is_on(self): + """Return if the service is on.""" + return self.api.data.get("status") == "enabled" + + async def async_turn_on(self, **kwargs): + """Turn on the service.""" + try: + await self.api.enable() + await self.async_update() + except HoleError as err: + _LOGGER.error("Unable to enable Pi-hole: %s", err) + + async def async_turn_off(self, **kwargs): + """Turn off the service.""" + await self.async_disable() + + async def async_disable(self, duration=None): + """Disable the service for a given duration.""" + duration_seconds = True # Disable infinitely by default + if duration is not None: + duration_seconds = duration.total_seconds() + _LOGGER.debug( + "Disabling Pi-hole '%s' (%s) for %d seconds", + self.name, + self.api.host, + duration_seconds, + ) + try: + await self.api.disable(duration_seconds) + await self.async_update() + except HoleError as err: + _LOGGER.error("Unable to disable Pi-hole: %s", err) diff --git a/homeassistant/components/pi_hole/translations/ca.json b/homeassistant/components/pi_hole/translations/ca.json index f9f3f1d37d7..134e635253b 100644 --- a/homeassistant/components/pi_hole/translations/ca.json +++ b/homeassistant/components/pi_hole/translations/ca.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "Clau API (opcional)", + "api_key": "Clau API", "host": "Amfitri\u00f3", + "location": "Ubicaci\u00f3", "name": "Nom", "port": "Port", "ssl": "Utilitza SSL", diff --git a/homeassistant/components/pi_hole/translations/en.json b/homeassistant/components/pi_hole/translations/en.json index ceefc0697cd..e6d579cecbc 100644 --- a/homeassistant/components/pi_hole/translations/en.json +++ b/homeassistant/components/pi_hole/translations/en.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "API Key (Optional)", + "api_key": "API Key", "host": "Host", + "location": "Location", "name": "Name", "port": "Port", "ssl": "Use SSL", diff --git a/homeassistant/components/pi_hole/translations/es.json b/homeassistant/components/pi_hole/translations/es.json index 9725843cef6..08391a45f63 100644 --- a/homeassistant/components/pi_hole/translations/es.json +++ b/homeassistant/components/pi_hole/translations/es.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "Clave API (Opcional)", + "api_key": "Clave API", "host": "Host", + "location": "Ubicaci\u00f3n", "name": "Nombre", "port": "Puerto", "ssl": "Usar SSL", diff --git a/homeassistant/components/pi_hole/translations/it.json b/homeassistant/components/pi_hole/translations/it.json index d8ee9d3c6b7..5b7329f31d6 100644 --- a/homeassistant/components/pi_hole/translations/it.json +++ b/homeassistant/components/pi_hole/translations/it.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "Chiave API (opzionale)", + "api_key": "Chiave API", "host": "Host", + "location": "Posizione", "name": "Nome", "port": "Porta", "ssl": "Utilizzare SSL", diff --git a/homeassistant/components/pi_hole/translations/ko.json b/homeassistant/components/pi_hole/translations/ko.json index 8d52c0fce2a..0f057e9c7be 100644 --- a/homeassistant/components/pi_hole/translations/ko.json +++ b/homeassistant/components/pi_hole/translations/ko.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "API \ud0a4 (\uc120\ud0dd \uc0ac\ud56d)", + "api_key": "API \ud0a4", "host": "\ud638\uc2a4\ud2b8", + "location": "\uc704\uce58", "name": "\uc774\ub984", "port": "\ud3ec\ud2b8", "ssl": "SSL \uc0ac\uc6a9", diff --git a/homeassistant/components/pi_hole/translations/lb.json b/homeassistant/components/pi_hole/translations/lb.json index 4224546df43..540462c889d 100644 --- a/homeassistant/components/pi_hole/translations/lb.json +++ b/homeassistant/components/pi_hole/translations/lb.json @@ -12,6 +12,7 @@ "data": { "api_key": "API Schl\u00ebssel (Optionell)", "host": "Host", + "location": "Standuert", "name": "Numm", "port": "Port", "ssl": "SSL benotzen", diff --git a/homeassistant/components/pi_hole/translations/nl.json b/homeassistant/components/pi_hole/translations/nl.json index 16ef25a15fa..7c399fc9ae6 100644 --- a/homeassistant/components/pi_hole/translations/nl.json +++ b/homeassistant/components/pi_hole/translations/nl.json @@ -6,6 +6,13 @@ }, "error": { "cannot_connect": "Kon niet verbinden" + }, + "step": { + "user": { + "data": { + "location": "Locatie" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/pi_hole/translations/no.json b/homeassistant/components/pi_hole/translations/no.json index f6e9203505c..e8bdbd2d18d 100644 --- a/homeassistant/components/pi_hole/translations/no.json +++ b/homeassistant/components/pi_hole/translations/no.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "API-n\u00f8kkel (valgfritt)", + "api_key": "API-n\u00f8kkel", "host": "Vert", + "location": "Beliggenhet", "name": "Navn", "port": "", "ssl": "Bruk SSL", diff --git a/homeassistant/components/pi_hole/translations/pl.json b/homeassistant/components/pi_hole/translations/pl.json index c4986e71aa7..f263736382b 100644 --- a/homeassistant/components/pi_hole/translations/pl.json +++ b/homeassistant/components/pi_hole/translations/pl.json @@ -12,6 +12,7 @@ "data": { "api_key": "Klucz API (opcjonalnie)", "host": "Nazwa hosta lub adres IP", + "location": "Lokalizacja", "name": "Nazwa", "port": "Port", "ssl": "U\u017cyj SSL", diff --git a/homeassistant/components/pi_hole/translations/ru.json b/homeassistant/components/pi_hole/translations/ru.json index 50cb5f98d16..ceb9e609d61 100644 --- a/homeassistant/components/pi_hole/translations/ru.json +++ b/homeassistant/components/pi_hole/translations/ru.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "\u041a\u043b\u044e\u0447 API (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "api_key": "\u041a\u043b\u044e\u0447 API", "host": "\u0425\u043e\u0441\u0442", + "location": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "port": "\u041f\u043e\u0440\u0442", "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c SSL", diff --git a/homeassistant/components/pi_hole/translations/sl.json b/homeassistant/components/pi_hole/translations/sl.json new file mode 100644 index 00000000000..cd46d19f38c --- /dev/null +++ b/homeassistant/components/pi_hole/translations/sl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "location": "Lokacija" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pi_hole/translations/zh-Hant.json b/homeassistant/components/pi_hole/translations/zh-Hant.json index 9864e557439..df1d3c44b6f 100644 --- a/homeassistant/components/pi_hole/translations/zh-Hant.json +++ b/homeassistant/components/pi_hole/translations/zh-Hant.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470\uff08\u9078\u9805\uff09", + "api_key": "API \u5bc6\u9470", "host": "\u4e3b\u6a5f\u7aef", + "location": "\u5ea7\u6a19", "name": "\u540d\u7a31", "port": "\u901a\u8a0a\u57e0", "ssl": "\u4f7f\u7528 SSL", diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index a9c69f4ddad..6db9d43eeda 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -10,9 +10,13 @@ import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity from homeassistant.const import CONF_HOST, CONF_NAME import homeassistant.helpers.config_validation as cv +from homeassistant.util.process import kill_subprocess + +from .const import PING_TIMEOUT _LOGGER = logging.getLogger(__name__) + ATTR_ROUND_TRIP_TIME_AVG = "round_trip_time_avg" ATTR_ROUND_TRIP_TIME_MAX = "round_trip_time_max" ATTR_ROUND_TRIP_TIME_MDEV = "round_trip_time_mdev" @@ -20,12 +24,14 @@ ATTR_ROUND_TRIP_TIME_MIN = "round_trip_time_min" CONF_PING_COUNT = "count" -DEFAULT_NAME = "Ping Binary sensor" +DEFAULT_NAME = "Ping" DEFAULT_PING_COUNT = 5 DEFAULT_DEVICE_CLASS = "connectivity" SCAN_INTERVAL = timedelta(minutes=5) +PARALLEL_UPDATES = 0 + PING_MATCHER = re.compile( r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" ) @@ -39,17 +45,19 @@ WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): vol.Range( + min=1, max=100 + ), } ) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Ping Binary sensor.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - count = config.get(CONF_PING_COUNT) + host = config[CONF_HOST] + count = config[CONF_PING_COUNT] + name = config.get(CONF_NAME, f"{DEFAULT_NAME} {host}") add_entities([PingBinarySensor(name, PingData(host, count))], True) @@ -129,7 +137,7 @@ class PingData: self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) try: - out = pinger.communicate() + out = pinger.communicate(timeout=self._count + PING_TIMEOUT) _LOGGER.debug("Output is %s", str(out)) if sys.platform == "win32": match = WIN32_PING_MATCHER.search(str(out).split("\n")[-1]) @@ -142,6 +150,9 @@ class PingData: match = PING_MATCHER.search(str(out).split("\n")[-1]) rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} + except subprocess.TimeoutExpired: + kill_subprocess(pinger) + return False except (subprocess.CalledProcessError, AttributeError): return False diff --git a/homeassistant/components/ping/const.py b/homeassistant/components/ping/const.py new file mode 100644 index 00000000000..8be8c1bdaa3 --- /dev/null +++ b/homeassistant/components/ping/const.py @@ -0,0 +1,3 @@ +"""Tracks devices by sending a ICMP echo request (ping).""" + +PING_TIMEOUT = 3 diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index c0effda7a55..f4e2e806143 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -14,9 +14,13 @@ from homeassistant.components.device_tracker.const import ( SOURCE_TYPE_ROUTER, ) import homeassistant.helpers.config_validation as cv +from homeassistant.util.process import kill_subprocess + +from .const import PING_TIMEOUT _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 CONF_PING_COUNT = "count" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -47,8 +51,12 @@ class Host: self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL ) try: - pinger.communicate() + pinger.communicate(timeout=1 + PING_TIMEOUT) return pinger.returncode == 0 + except subprocess.TimeoutExpired: + kill_subprocess(pinger) + return False + except subprocess.CalledProcessError: return False diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 4556422dd00..85d4b43b532 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -222,6 +222,9 @@ def play_on_sonos(hass, service_call): if isinstance(content, int): content = {"plex_key": content} + content_type = PLEX_DOMAIN + else: + content_type = "music" plex_server_name = content.get("plex_server") shuffle = content.pop("shuffle", 0) @@ -246,7 +249,7 @@ def play_on_sonos(hass, service_call): ) return - media = plex_server.lookup_media("music", **content) + media = plex_server.lookup_media(content_type, **content) if media is None: _LOGGER.error("Media could not be found: %s", content) return diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index d467b962dad..1b7db505e29 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -10,6 +10,7 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -304,7 +305,7 @@ class PlexMediaPlayer(MediaPlayerEntity): self._state = STATE_OFF def _set_media_type(self): - if self._session_type in ["clip", "episode"]: + if self._session_type == "episode": self._media_content_type = MEDIA_TYPE_TVSHOW # season number (00) @@ -334,6 +335,12 @@ class PlexMediaPlayer(MediaPlayerEntity): ) self._media_artist = self._media_album_artist + elif self._session_type == "clip": + _LOGGER.debug( + "Clip content type detected, compatibility may vary: %s", self.name + ) + self._media_content_type = MEDIA_TYPE_VIDEO + def force_idle(self): """Force client to idle.""" self._player_state = STATE_IDLE @@ -397,19 +404,7 @@ class PlexMediaPlayer(MediaPlayerEntity): @property def media_content_type(self): """Return the content type of current playing media.""" - if self._session_type == "clip": - _LOGGER.debug( - "Clip content type detected, compatibility may vary: %s", self.name - ) - return MEDIA_TYPE_TVSHOW - if self._session_type == "episode": - return MEDIA_TYPE_TVSHOW - if self._session_type == "movie": - return MEDIA_TYPE_MOVIE - if self._session_type == "track": - return MEDIA_TYPE_MUSIC - - return None + return self._media_content_type @property def media_artist(self): diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 47d12fb35d2..a3b465dfdb0 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -82,7 +82,7 @@ class PlexSensor(Entity): now_playing_user = f"{user} - {device}" now_playing_title = "" - if sess.TYPE in ["clip", "episode"]: + if sess.TYPE == "episode": # example: # "Supernatural (2005) - s01e13 - Route 666" @@ -111,7 +111,7 @@ class PlexSensor(Entity): track_album = sess.parentTitle track_title = sess.title now_playing_title = f"{track_artist} - {track_album} - {track_title}" - else: + elif sess.TYPE == "movie": # example: # "picture_of_last_summer_camp (2015)" # "The Incredible Hulk (2008)" @@ -119,6 +119,8 @@ class PlexSensor(Entity): year = await self.hass.async_add_executor_job(getattr, sess, "year") if year is not None: now_playing_title += f" ({year})" + else: + now_playing_title = sess.title now_playing.append((now_playing_user, now_playing_title)) self._state = len(self.sessions) diff --git a/homeassistant/components/plex/translations/nl.json b/homeassistant/components/plex/translations/nl.json index da13477c4af..142f5f8bec7 100644 --- a/homeassistant/components/plex/translations/nl.json +++ b/homeassistant/components/plex/translations/nl.json @@ -16,6 +16,11 @@ }, "flow_title": "{naam} ({host})", "step": { + "manual_setup": { + "data": { + "port": "Poort" + } + }, "select_server": { "data": { "server": "Server" diff --git a/homeassistant/components/plex/translations/no.json b/homeassistant/components/plex/translations/no.json index ab72275070a..ab6c8232985 100644 --- a/homeassistant/components/plex/translations/no.json +++ b/homeassistant/components/plex/translations/no.json @@ -20,6 +20,8 @@ "step": { "manual_setup": { "data": { + "host": "Vert", + "port": "", "ssl": "Bruk SSL", "token": "Token (valgfritt)", "verify_ssl": "Verifisere SSL-sertifikat" @@ -35,13 +37,13 @@ }, "user": { "description": "Fortsett til [plex.tv] (https://plex.tv) for \u00e5 koble en Plex-server.", - "title": "Plex Media Server" + "title": "" }, "user_advanced": { "data": { "setup_method": "Oppsettmetode" }, - "title": "Plex Media Server" + "title": "" } } }, diff --git a/homeassistant/components/plugwise/translations/lb.json b/homeassistant/components/plugwise/translations/lb.json index ea9785d2039..8b0ea38c2f6 100644 --- a/homeassistant/components/plugwise/translations/lb.json +++ b/homeassistant/components/plugwise/translations/lb.json @@ -8,6 +8,7 @@ "invalid_auth": "Ong\u00eblteg Authentifikatioun, iwwerpr\u00e9if d\u00e9i 8 Charakteren vun denger Smile ID", "unknown": "Onerwaarte Feeler" }, + "flow_title": "Smile: {name}", "step": { "user": { "data": { diff --git a/homeassistant/components/plugwise/translations/pl.json b/homeassistant/components/plugwise/translations/pl.json index 8c639bd8865..135b9d838fc 100644 --- a/homeassistant/components/plugwise/translations/pl.json +++ b/homeassistant/components/plugwise/translations/pl.json @@ -8,7 +8,7 @@ "invalid_auth": "Nieudane uwierzytelnienie, sprawd\u017a Smile ID", "unknown": "Nieoczekiwany b\u0142\u0105d." }, - "flow_title": "U\u015bmiech: {name}", + "flow_title": "Smile: {name}", "step": { "user": { "data": { diff --git a/homeassistant/components/plum_lightpad/translations/no.json b/homeassistant/components/plum_lightpad/translations/no.json new file mode 100644 index 00000000000..f4f16565659 --- /dev/null +++ b/homeassistant/components/plum_lightpad/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes." + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "E-post" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plum_lightpad/translations/pl.json b/homeassistant/components/plum_lightpad/translations/pl.json index 063db5c268a..121744d0f0d 100644 --- a/homeassistant/components/plum_lightpad/translations/pl.json +++ b/homeassistant/components/plum_lightpad/translations/pl.json @@ -1,9 +1,16 @@ { "config": { + "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia." + }, "step": { "user": { "data": { - "password": "Has\u0142o" + "password": "[%key_id:common::config_flow::data::password%]", + "username": "[%key_id:common::config_flow::data::email%]" } } } diff --git a/homeassistant/components/point/translations/es.json b/homeassistant/components/point/translations/es.json index 04e0498fcdf..a7247b3d9b3 100644 --- a/homeassistant/components/point/translations/es.json +++ b/homeassistant/components/point/translations/es.json @@ -3,16 +3,16 @@ "abort": { "already_setup": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n", - "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n", + "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.", "external_setup": "Point se ha configurado correctamente a partir de otro flujo.", - "no_flows": "Es necesario configurar Point antes de poder autenticarse con \u00e9l. [Echa un vistazo a las instrucciones] (https://www.home-assistant.io/components/point/)." + "no_flows": "El componente no est\u00e1 configurado. Consulta la documentaci\u00f3n." }, "create_entry": { - "default": "Autenticado correctamente con Minut para tu(s) dispositivo(s) Point" + "default": "Autenticado correctamente" }, "error": { "follow_link": "Accede al enlace e identif\u00edcate antes de pulsar Enviar.", - "no_token": "No autenticado con Minut" + "no_token": "Token de acceso no v\u00e1lido" }, "step": { "auth": { @@ -23,8 +23,8 @@ "data": { "flow_impl": "Proveedor" }, - "description": "Elige a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n quieres autenticarte con Point.", - "title": "Proveedor de autenticaci\u00f3n" + "description": "\u00bfQuieres comenzar a configurar?", + "title": "Selecciona el m\u00e9todo de autenticaci\u00f3n" } } } diff --git a/homeassistant/components/point/translations/no.json b/homeassistant/components/point/translations/no.json index 2b907e42c3a..f9fb69d7f67 100644 --- a/homeassistant/components/point/translations/no.json +++ b/homeassistant/components/point/translations/no.json @@ -12,7 +12,7 @@ }, "error": { "follow_link": "Vennligst f\u00f8lg lenken og godkjenn f\u00f8r du trykker p\u00e5 Send", - "no_token": "Ikke godkjent med Minut" + "no_token": "Ugyldig tilgangstoken" }, "step": { "auth": { diff --git a/homeassistant/components/poolsense/translations/no.json b/homeassistant/components/poolsense/translations/no.json index e99a1d62746..edf3a8a9463 100644 --- a/homeassistant/components/poolsense/translations/no.json +++ b/homeassistant/components/poolsense/translations/no.json @@ -1,7 +1,20 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, "step": { "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "description": "[%key:common::config_flow::description%]", "title": "" } } diff --git a/homeassistant/components/poolsense/translations/pl.json b/homeassistant/components/poolsense/translations/pl.json new file mode 100644 index 00000000000..29d7011f0c1 --- /dev/null +++ b/homeassistant/components/poolsense/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "[%key_id:common::config_flow::data::password%]" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 654e6245a57..1a70e4cf78e 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -225,7 +225,7 @@ class PrometheusMetrics: try: value = state_helper.state_as_number(state) except ValueError: - _LOGGER.warning("Could not convert %s to float", state) + _LOGGER.debug("Could not convert %s to float", state) value = 0 return value diff --git a/homeassistant/components/ps4/translations/pl.json b/homeassistant/components/ps4/translations/pl.json index 8cbdfe3d0b7..01698f26554 100644 --- a/homeassistant/components/ps4/translations/pl.json +++ b/homeassistant/components/ps4/translations/pl.json @@ -3,7 +3,7 @@ "abort": { "credential_error": "B\u0142\u0105d podczas pobierania danych logowania.", "devices_configured": "Wszystkie znalezione urz\u0105dzenia s\u0105 ju\u017c skonfigurowane.", - "no_devices_found": "W sieci nie znaleziono urz\u0105dze\u0144 PlayStation 4.", + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 PlayStation 4.", "port_987_bind_error": "Nie mo\u017cna powi\u0105za\u0107 z portem 987.", "port_997_bind_error": "Nie mo\u017cna powi\u0105za\u0107 z portem 997." }, diff --git a/homeassistant/components/rachio/translations/es.json b/homeassistant/components/rachio/translations/es.json index ee7bdfeb26d..7e4a03c138a 100644 --- a/homeassistant/components/rachio/translations/es.json +++ b/homeassistant/components/rachio/translations/es.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "api_key": "La clave API para la cuenta Rachio." + "api_key": "Clave API" }, "description": "Necesitar\u00e1s la clave API de https://app.rach.io/. Selecciona 'Account Settings' y luego haz clic en 'GET API KEY'.", "title": "Conectar a tu dispositivo Rachio" diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index cf604714106..89ca65fd44b 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -2,6 +2,6 @@ "domain": "rainbird", "name": "Rain Bird", "documentation": "https://www.home-assistant.io/integrations/rainbird", - "requirements": ["pyrainbird==0.4.1"], + "requirements": ["pyrainbird==0.4.2"], "codeowners": ["@konikvranik"] } diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 2e32d0ed43d..239878d0219 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -133,7 +133,7 @@ async def async_setup_entry(hass, config_entry): _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) - client = Client(websession) + client = Client(session=websession) try: await client.load_local( diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index dc1ee16d05f..d0513ac89fb 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -1,5 +1,5 @@ """Config flow to configure the RainMachine component.""" -from regenmaschine import login +from regenmaschine import Client from regenmaschine.errors import RainMachineError import voluptuous as vol @@ -59,12 +59,12 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) + client = Client(session=websession) try: - await login( + await client.load_local( user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], - websession, port=user_input[CONF_PORT], ssl=user_input.get(CONF_SSL, True), ) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index aed0f030c25..07321801381 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,6 +3,6 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==1.5.1"], + "requirements": ["regenmaschine==2.1.0"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/rainmachine/translations/no.json b/homeassistant/components/rainmachine/translations/no.json index cf031e13f10..bc80cdedb31 100644 --- a/homeassistant/components/rainmachine/translations/no.json +++ b/homeassistant/components/rainmachine/translations/no.json @@ -12,7 +12,7 @@ "data": { "ip_address": "Vertsnavn eller IP-adresse", "password": "Passord", - "port": "Port" + "port": "" }, "title": "Fyll ut informasjonen din" } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index c64b9429cf0..d0c18256377 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,14 +35,12 @@ from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from . import migration, purge -from .const import DATA_INSTANCE +from .const import DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX from .models import Base, Events, RecorderRuns, States -from .util import session_scope +from .util import session_scope, validate_or_move_away_sqlite_database _LOGGER = logging.getLogger(__name__) -DOMAIN = "recorder" - SERVICE_PURGE = "purge" ATTR_KEEP_DAYS = "keep_days" @@ -510,7 +508,7 @@ class Recorder(threading.Thread): # We do not import sqlite3 here so mysql/other # users do not have to pay for it to be loaded in # memory - if self.db_url.startswith("sqlite://"): + if self.db_url.startswith(SQLITE_URL_PREFIX): old_isolation = dbapi_connection.isolation_level dbapi_connection.isolation_level = None cursor = dbapi_connection.cursor() @@ -526,13 +524,18 @@ class Recorder(threading.Thread): cursor.execute("SET session wait_timeout=28800") cursor.close() - if self.db_url == "sqlite://" or ":memory:" in self.db_url: + if self.db_url == SQLITE_URL_PREFIX or ":memory:" in self.db_url: kwargs["connect_args"] = {"check_same_thread": False} kwargs["poolclass"] = StaticPool kwargs["pool_reset_on_return"] = None else: kwargs["echo"] = False + if self.db_url != SQLITE_URL_PREFIX and self.db_url.startswith( + SQLITE_URL_PREFIX + ): + validate_or_move_away_sqlite_database(self.db_url) + if self.engine is not None: self.engine.dispose() diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index ed0950b6c6f..b2ffc91fdb4 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,3 +1,5 @@ """Recorder constants.""" DATA_INSTANCE = "recorder_instance" +SQLITE_URL_PREFIX = "sqlite://" +DOMAIN = "recorder" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index d8b508ba513..e88852e4a5a 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,22 +1,19 @@ """Schema migration helpers.""" import logging -import os from sqlalchemy import Table, text from sqlalchemy.engine import reflection from sqlalchemy.exc import InternalError, OperationalError, SQLAlchemyError +from .const import DOMAIN from .models import SCHEMA_VERSION, Base, SchemaChanges from .util import session_scope _LOGGER = logging.getLogger(__name__) -PROGRESS_FILE = ".migration_progress" def migrate_schema(instance): """Check if the schema needs to be upgraded.""" - progress_path = instance.hass.config.path(PROGRESS_FILE) - with session_scope(session=instance.get_session()) as session: res = ( session.query(SchemaChanges) @@ -32,20 +29,13 @@ def migrate_schema(instance): ) if current_version == SCHEMA_VERSION: - # Clean up if old migration left file - if os.path.isfile(progress_path): - _LOGGER.warning("Found existing migration file, cleaning up") - os.remove(instance.hass.config.path(PROGRESS_FILE)) return - with open(progress_path, "w"): - pass - _LOGGER.warning( "Database is about to upgrade. Schema version: %s", current_version ) - try: + with instance.hass.timeout.freeze(DOMAIN): for version in range(current_version, SCHEMA_VERSION): new_version = version + 1 _LOGGER.info("Upgrading recorder db schema to version %s", new_version) @@ -53,8 +43,6 @@ def migrate_schema(instance): session.add(SchemaChanges(schema_version=new_version)) _LOGGER.info("Upgrade to version %s done", new_version) - finally: - os.remove(instance.hass.config.path(PROGRESS_FILE)) def _create_index(engine, table_name, index_name): diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 883bc41e71b..8a59cc42a33 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,16 +1,20 @@ """SQLAlchemy util functions.""" from contextlib import contextmanager import logging +import os import time from sqlalchemy.exc import OperationalError, SQLAlchemyError -from .const import DATA_INSTANCE +import homeassistant.util.dt as dt_util + +from .const import DATA_INSTANCE, SQLITE_URL_PREFIX _LOGGER = logging.getLogger(__name__) RETRIES = 3 QUERY_RETRY_WAIT = 0.1 +SQLITE3_POSTFIXES = ["", "-wal", "-shm"] @contextmanager @@ -59,6 +63,7 @@ def execute(qry, to_native=False, validate_entity_ids=True): This method also retries a few times in the case of stale connections. """ + for tryno in range(0, RETRIES): try: timer_start = time.perf_counter() @@ -94,3 +99,52 @@ def execute(qry, to_native=False, validate_entity_ids=True): if tryno == RETRIES - 1: raise time.sleep(QUERY_RETRY_WAIT) + + +def validate_or_move_away_sqlite_database(dburl: str) -> bool: + """Ensure that the database is valid or move it away.""" + dbpath = dburl[len(SQLITE_URL_PREFIX) :] + + if not os.path.exists(dbpath): + # Database does not exist yet, this is OK + return True + + if not validate_sqlite_database(dbpath): + _move_away_broken_database(dbpath) + return False + + return True + + +def validate_sqlite_database(dbpath: str) -> bool: + """Run a quick check on an sqlite database to see if it is corrupt.""" + import sqlite3 # pylint: disable=import-outside-toplevel + + try: + conn = sqlite3.connect(dbpath) + conn.cursor().execute("PRAGMA QUICK_CHECK") + conn.close() + except sqlite3.DatabaseError: + _LOGGER.exception("The database at %s is corrupt or malformed.", dbpath) + return False + + return True + + +def _move_away_broken_database(dbfile: str) -> None: + """Move away a broken sqlite3 database.""" + + isotime = dt_util.utcnow().isoformat() + corrupt_postfix = f".corrupt.{isotime}" + + _LOGGER.error( + "The system will rename the corrupt database file %s to %s in order to allow startup to proceed", + dbfile, + f"{dbfile}{corrupt_postfix}", + ) + + for postfix in SQLITE3_POSTFIXES: + path = f"{dbfile}{postfix}" + if not os.path.exists(path): + continue + os.rename(path, f"{path}{corrupt_postfix}") diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index f8b99c48a44..1290912897d 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -134,7 +134,7 @@ async def async_setup(hass, config): ) except asyncio.TimeoutError: - _LOGGER.warning("Timeout call %s", response.url, exc_info=1) + _LOGGER.warning("Timeout call %s", request_url, exc_info=1) except aiohttp.ClientError: _LOGGER.error("Client error %s", request_url, exc_info=1) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 576e38316c6..04d2078e182 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -1,4 +1,5 @@ """Support for RFXtrx devices.""" +import asyncio import binascii from collections import OrderedDict import logging @@ -13,10 +14,10 @@ from homeassistant.const import ( CONF_COMMAND_ON, CONF_DEVICE, CONF_DEVICE_CLASS, + CONF_DEVICE_ID, CONF_DEVICES, CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, POWER_WATT, TEMP_CELSIUS, @@ -29,7 +30,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ATTR_EVENT, - DATA_RFXTRX_CONFIG, DEVICE_PACKET_TYPE_LIGHTING4, EVENT_RFXTRX_EVENT, SERVICE_SEND, @@ -83,6 +83,7 @@ DATA_TYPES = OrderedDict( _LOGGER = logging.getLogger(__name__) DATA_RFXOBJECT = "rfxobject" +DATA_LISTENER = "ha_stop" def _bytearray_string(data): @@ -105,7 +106,9 @@ DEVICE_DATA_SCHEMA = vol.Schema( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, - vol.Optional(CONF_OFF_DELAY): vol.Any(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_OFF_DELAY): vol.All( + cv.time_period, cv.positive_timedelta, lambda value: value.total_seconds() + ), vol.Optional(CONF_DATA_BITS): cv.positive_int, vol.Optional(CONF_COMMAND_ON): cv.byte, vol.Optional(CONF_COMMAND_OFF): cv.byte, @@ -131,25 +134,34 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Any(DEVICE_SCHEMA, PORT_SCHEMA)}, extra=vol.ALLOW_EXTRA ) +DOMAINS = ["switch", "sensor", "light", "binary_sensor", "cover"] + async def async_setup(hass, config): """Set up the RFXtrx component.""" if DOMAIN not in config: - hass.data[DATA_RFXTRX_CONFIG] = BASE_SCHEMA({}) return True - hass.data[DATA_RFXTRX_CONFIG] = config[DOMAIN] + data = { + CONF_HOST: config[DOMAIN].get(CONF_HOST), + CONF_PORT: config[DOMAIN].get(CONF_PORT), + CONF_DEVICE: config[DOMAIN].get(CONF_DEVICE), + CONF_DEBUG: config[DOMAIN].get(CONF_DEBUG), + CONF_AUTOMATIC_ADD: config[DOMAIN].get(CONF_AUTOMATIC_ADD), + CONF_DEVICES: config[DOMAIN][CONF_DEVICES], + } + + # Read device_id from the event code add to the data that will end up in the ConfigEntry + for event_code, event_config in data[CONF_DEVICES].items(): + event = get_rfx_object(event_code) + device_id = get_device_id( + event.device, data_bits=event_config.get(CONF_DATA_BITS) + ) + event_config[CONF_DEVICE_ID] = device_id hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: config[DOMAIN].get(CONF_HOST), - CONF_PORT: config[DOMAIN].get(CONF_PORT), - CONF_DEVICE: config[DOMAIN].get(CONF_DEVICE), - CONF_DEBUG: config[DOMAIN][CONF_DEBUG], - }, + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data, ) ) return True @@ -157,9 +169,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up the RFXtrx component.""" - await hass.async_add_executor_job(setup_internal, hass, entry.data) + hass.data.setdefault(DOMAIN, {}) - for domain in ["switch", "sensor", "light", "binary_sensor", "cover"]: + await async_setup_internal(hass, entry) + + for domain in DOMAINS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, domain) ) @@ -167,23 +181,70 @@ async def async_setup_entry(hass, entry: config_entries.ConfigEntry): return True -def setup_internal(hass, config): - """Set up the RFXtrx component.""" +async def async_unload_entry(hass, entry: config_entries.ConfigEntry): + """Unload RFXtrx component.""" + if not all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in DOMAINS + ] + ) + ): + return False - # Setup some per device config - device_events = set() - device_bits = {} - for event_code, event_config in hass.data[DATA_RFXTRX_CONFIG][CONF_DEVICES].items(): + hass.services.async_remove(DOMAIN, SERVICE_SEND) + + listener = hass.data[DOMAIN][DATA_LISTENER] + listener() + + rfx_object = hass.data[DOMAIN][DATA_RFXOBJECT] + await hass.async_add_executor_job(rfx_object.close_connection) + + return True + + +def _create_rfx(config): + """Construct a rfx object based on config.""" + if config[CONF_PORT] is not None: + # If port is set then we create a TCP connection + rfx = rfxtrxmod.Connect( + (config[CONF_HOST], config[CONF_PORT]), + None, + debug=config[CONF_DEBUG], + transport_protocol=rfxtrxmod.PyNetworkTransport, + ) + else: + rfx = rfxtrxmod.Connect(config[CONF_DEVICE], None, debug=config[CONF_DEBUG]) + + return rfx + + +def _get_device_lookup(devices): + """Get a lookup structure for devices.""" + lookup = dict() + for event_code, event_config in devices.items(): event = get_rfx_object(event_code) device_id = get_device_id( event.device, data_bits=event_config.get(CONF_DATA_BITS) ) - device_bits[device_id] = event_config.get(CONF_DATA_BITS) - if event_config[CONF_FIRE_EVENT]: - device_events.add(device_id) + lookup[device_id] = event_config + return lookup + + +async def async_setup_internal(hass, entry: config_entries.ConfigEntry): + """Set up the RFXtrx component.""" + config = entry.data + + # Initialize library + rfx_object = await hass.async_add_executor_job(_create_rfx, config) + + # Setup some per device config + devices = _get_device_lookup(config[CONF_DEVICES]) # Declare the Handle event - def handle_receive(event): + @callback + def async_handle_receive(event): """Handle received messages from RFXtrx gateway.""" # Log RFXCOM event if not event.device.id_string: @@ -200,50 +261,49 @@ def setup_internal(hass, config): _LOGGER.debug("Receive RFXCOM event: %s", event_data) - data_bits = get_device_data_bits(event.device, device_bits) + data_bits = get_device_data_bits(event.device, devices) device_id = get_device_id(event.device, data_bits=data_bits) + # Register new devices + if config[CONF_AUTOMATIC_ADD] and device_id not in devices: + _add_device(event, device_id) + # Callback to HA registered components. - hass.helpers.dispatcher.dispatcher_send(SIGNAL_EVENT, event, device_id) + hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_EVENT, event, device_id) # Signal event to any other listeners - if device_id in device_events: - hass.bus.fire(EVENT_RFXTRX_EVENT, event_data) + fire_event = devices.get(device_id, {}).get(CONF_FIRE_EVENT) + if fire_event: + hass.bus.async_fire(EVENT_RFXTRX_EVENT, event_data) - device = config[CONF_DEVICE] - host = config[CONF_HOST] - port = config[CONF_PORT] - debug = config[CONF_DEBUG] + @callback + def _add_device(event, device_id): + """Add a device to config entry.""" + config = DEVICE_DATA_SCHEMA({}) + config[CONF_DEVICE_ID] = device_id - if port is not None: - # If port is set then we create a TCP connection - rfx_object = rfxtrxmod.Connect( - (host, port), - None, - debug=debug, - transport_protocol=rfxtrxmod.PyNetworkTransport, - ) - else: - rfx_object = rfxtrxmod.Connect(device, None, debug=debug) - - def _start_rfxtrx(event): - rfx_object.event_callback = handle_receive - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) + data = entry.data.copy() + event_code = binascii.hexlify(event.data).decode("ASCII") + data[CONF_DEVICES][event_code] = config + hass.config_entries.async_update_entry(entry=entry, data=data) + devices[device_id] = config def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" rfx_object.close_connection() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) + listener = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) - hass.data[DATA_RFXOBJECT] = rfx_object + hass.data[DOMAIN][DATA_LISTENER] = listener + hass.data[DOMAIN][DATA_RFXOBJECT] = rfx_object + + rfx_object.event_callback = lambda event: hass.add_job(async_handle_receive, event) def send(call): event = call.data[ATTR_EVENT] rfx_object.transport.send(event) - hass.services.register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) def get_rfx_object(packetid): @@ -295,11 +355,12 @@ def get_pt2262_cmd(device_id, data_bits): return hex(data[-1] & mask) -def get_device_data_bits(device, device_bits): +def get_device_data_bits(device, devices): """Deduce data bits for device based on a cache of device bits.""" data_bits = None if device.packettype == DEVICE_PACKET_TYPE_LIGHTING4: - for device_id, bits in device_bits.items(): + for device_id, entity_config in devices.items(): + bits = entity_config.get(CONF_DATA_BITS) if get_device_id(device, bits) == device_id: data_bits = bits break @@ -426,38 +487,7 @@ class RfxtrxCommandEntity(RfxtrxEntity): self.signal_repetitions = signal_repetitions self._state = None - def _send_command(self, command, brightness=0): - rfx_object = self.hass.data[DATA_RFXOBJECT] - - if command == "turn_on": - for _ in range(self.signal_repetitions): - self._device.send_on(rfx_object.transport) - self._state = True - - elif command == "dim": - for _ in range(self.signal_repetitions): - self._device.send_dim(rfx_object.transport, brightness) - self._state = True - - elif command == "turn_off": - for _ in range(self.signal_repetitions): - self._device.send_off(rfx_object.transport) - self._state = False - - elif command == "roll_up": - for _ in range(self.signal_repetitions): - self._device.send_open(rfx_object.transport) - self._state = True - - elif command == "roll_down": - for _ in range(self.signal_repetitions): - self._device.send_close(rfx_object.transport) - self._state = False - - elif command == "stop_roll": - for _ in range(self.signal_repetitions): - self._device.send_stop(rfx_object.transport) - self._state = True - - if self.hass: - self.schedule_update_ha_state() + async def _async_send(self, fun, *args): + rfx_object = self.hass.data[DOMAIN][DATA_RFXOBJECT] + for _ in range(self.signal_repetitions): + await self.hass.async_add_executor_job(fun, rfx_object.transport, *args) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 3f0010b139e..8f7229299ee 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -3,12 +3,17 @@ import logging import RFXtrx as rfxtrxmod -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + DEVICE_CLASS_SMOKE, + BinarySensorEntity, +) from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_DEVICE_CLASS, CONF_DEVICES, + STATE_ON, ) from homeassistant.core import callback from homeassistant.helpers import event as evt @@ -24,17 +29,38 @@ from . import ( get_pt2262_cmd, get_rfx_object, ) -from .const import ( - ATTR_EVENT, - COMMAND_OFF_LIST, - COMMAND_ON_LIST, - DATA_RFXTRX_CONFIG, - DEVICE_PACKET_TYPE_LIGHTING4, -) +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DEVICE_PACKET_TYPE_LIGHTING4 _LOGGER = logging.getLogger(__name__) +SENSOR_STATUS_ON = [ + "Panic", + "Motion", + "Motion Tamper", + "Light Detected", + "Alarm", + "Alarm Tamper", +] + +SENSOR_STATUS_OFF = [ + "End Panic", + "No Motion", + "No Motion Tamper", + "Dark Detected", + "Normal", + "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, +} + + async def async_setup_entry( hass, config_entry, async_add_entities, ): @@ -44,12 +70,19 @@ async def async_setup_entry( device_ids = set() pt2262_devices = [] - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data def supported(event): - return isinstance(event, rfxtrxmod.ControlEvent) + if isinstance(event, rfxtrxmod.ControlEvent): + return True + if isinstance(event, rfxtrxmod.SensorEvent): + return event.values.get("Sensor Status") in [ + *SENSOR_STATUS_ON, + *SENSOR_STATUS_OFF, + ] + return False - for packet_id, entity in discovery_info[CONF_DEVICES].items(): + for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): event = get_rfx_object(packet_id) if event is None: _LOGGER.error("Invalid device: %s", packet_id) @@ -57,7 +90,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device, data_bits=entity.get(CONF_DATA_BITS)) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) if device_id in device_ids: continue device_ids.add(device_id) @@ -69,11 +104,14 @@ async def async_setup_entry( device = RfxtrxBinarySensor( event.device, device_id, - entity.get(CONF_DEVICE_CLASS), - entity.get(CONF_OFF_DELAY), - entity.get(CONF_DATA_BITS), - entity.get(CONF_COMMAND_ON), - entity.get(CONF_COMMAND_OFF), + entity_info.get( + CONF_DEVICE_CLASS, + DEVICE_TYPE_DEVICE_CLASS.get(event.device.type_string), + ), + entity_info.get(CONF_OFF_DELAY), + entity_info.get(CONF_DATA_BITS), + entity_info.get(CONF_COMMAND_ON), + entity_info.get(CONF_COMMAND_OFF), ) sensors.append(device) @@ -96,7 +134,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) + sensor = RfxtrxBinarySensor( + event.device, + device_id, + event=event, + device_class=DEVICE_TYPE_DEVICE_CLASS.get(event.device.type_string), + ) async_add_entities([sensor]) # Subscribe to main RFXtrx events @@ -137,9 +180,10 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): if self._event is None: old_state = await self.async_get_last_state() if old_state is not None: - event = old_state.attributes.get(ATTR_EVENT) - if event: - self._apply_event(get_rfx_object(event)) + self._state = old_state.state == STATE_ON + + if self._state and self._off_delay is not None: + self._state = False @property def force_update(self) -> bool: @@ -169,9 +213,13 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self._state = True def _apply_event_standard(self, event): - if event.values["Command"] in COMMAND_ON_LIST: + if event.values.get("Command") in COMMAND_ON_LIST: self._state = True - elif event.values["Command"] in COMMAND_OFF_LIST: + elif event.values.get("Command") in COMMAND_OFF_LIST: + self._state = False + elif event.values.get("Sensor Status") in SENSOR_STATUS_ON: + self._state = True + elif event.values.get("Sensor Status") in SENSOR_STATUS_OFF: self._state = False def _apply_event(self, event): @@ -199,7 +247,11 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self.async_write_ha_state() - if self.is_on and self._off_delay is not None and self._delay_listener is None: + if self._delay_listener: + self._delay_listener() + self._delay_listener = None + + if self.is_on and self._off_delay is not None: @callback def off_delay_listener(now): @@ -209,5 +261,5 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self.async_write_ha_state() self._delay_listener = evt.async_call_later( - self.hass, self._off_delay.total_seconds(), off_delay_listener + self.hass, self._off_delay, off_delay_listener ) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 0bd8854ca41..287e1ec4baf 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -16,7 +16,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config=None): """Handle the initial step.""" - - await self.async_set_unique_id(DOMAIN) - self._abort_if_unique_id_configured(import_config) + entry = await self.async_set_unique_id(DOMAIN) + if entry and import_config.items() != entry.data.items(): + self.hass.config_entries.async_update_entry(entry, data=import_config) + return self.async_abort(reason="already_configured") return self.async_create_entry(title="RFXTRX", data=import_config) diff --git a/homeassistant/components/rfxtrx/const.py b/homeassistant/components/rfxtrx/const.py index 7626c082f45..c0436bfcf60 100644 --- a/homeassistant/components/rfxtrx/const.py +++ b/homeassistant/components/rfxtrx/const.py @@ -7,12 +7,14 @@ COMMAND_ON_LIST = [ "Stop", "Open (inline relay)", "Stop (inline relay)", + "Enable sun automation", ] COMMAND_OFF_LIST = [ "Off", "Down", "Close (inline relay)", + "Disable sun automation", ] ATTR_EVENT = "event" @@ -21,5 +23,4 @@ SERVICE_SEND = "send" DEVICE_PACKET_TYPE_LIGHTING4 = 0x13 -DATA_RFXTRX_CONFIG = "rfxtrx_config" EVENT_RFXTRX_EVENT = "rfxtrx_event" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 41df8d022ec..8b5886191a5 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -15,7 +15,7 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DATA_RFXTRX_CONFIG +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST _LOGGER = logging.getLogger(__name__) @@ -24,7 +24,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, ): """Set up config entry.""" - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data device_ids = set() def supported(event): @@ -98,17 +98,23 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): """Return if the cover is closed.""" return not self._state - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up.""" - self._send_command("roll_up") + await self._async_send(self._device.send_open) + self._state = True + self.async_write_ha_state() - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down.""" - self._send_command("roll_down") + await self._async_send(self._device.send_close) + self._state = False + self.async_write_ha_state() - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" - self._send_command("stop_roll") + await self._async_send(self._device.send_stop) + self._state = True + self.async_write_ha_state() def _apply_event(self, event): """Apply command from rfxtrx.""" diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 3e3ef95fbf7..44472b6c33c 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -21,7 +21,7 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DATA_RFXTRX_CONFIG +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, ): """Set up config entry.""" - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data device_ids = set() def supported(event): @@ -125,21 +125,25 @@ class RfxtrxLight(RfxtrxCommandEntity, LightEntity): """Return true if device is on.""" return self._state - def turn_on(self, **kwargs): - """Turn the light on.""" + async def async_turn_on(self, **kwargs): + """Turn the device on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) + self._state = True if brightness is None: + await self._async_send(self._device.send_on) self._brightness = 255 - self._send_command("turn_on") else: + await self._async_send(self._device.send_dim, brightness * 100 // 255) self._brightness = brightness - _brightness = brightness * 100 // 255 - self._send_command("dim", _brightness) - def turn_off(self, **kwargs): + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): """Turn the device off.""" + await self._async_send(self._device.send_off) + self._state = False self._brightness = 0 - self._send_command("turn_off") + self.async_write_ha_state() def _apply_event(self, event): """Apply command from rfxtrx.""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index fb7176d2f91..8110e8d8c6c 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -21,7 +21,7 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import ATTR_EVENT, DATA_RFXTRX_CONFIG +from .const import ATTR_EVENT _LOGGER = logging.getLogger(__name__) @@ -58,14 +58,14 @@ async def async_setup_entry( hass, config_entry, async_add_entities, ): """Set up platform.""" - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data data_ids = set() def supported(event): return isinstance(event, (ControlEvent, SensorEvent)) entities = [] - for packet_id, entity in discovery_info[CONF_DEVICES].items(): + for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): event = get_rfx_object(packet_id) if event is None: _LOGGER.error("Invalid device: %s", packet_id) @@ -73,7 +73,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device, data_bits=entity.get(CONF_DATA_BITS)) + 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): data_id = (*device_id, data_type) if data_id in data_ids: @@ -169,9 +171,6 @@ class RfxtrxSensor(RfxtrxEntity): @callback def _handle_event(self, event, device_id): """Check if event applies to me and update.""" - if not isinstance(event, SensorEvent): - return - if device_id != self._device_id: return diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 7a73a41bfdf..e19265dec32 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -1,2 +1,9 @@ { -} \ No newline at end of file + "config": { + "step": {}, + "error": {}, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 6cd9a484abd..9b2c3c60539 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -18,7 +18,7 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DATA_RFXTRX_CONFIG +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST DATA_SWITCH = f"{DOMAIN}_switch" @@ -29,7 +29,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, ): """Set up config entry.""" - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data device_ids = set() def supported(event): @@ -37,6 +37,7 @@ async def async_setup_entry( isinstance(event.device, rfxtrxmod.LightingDevice) and not event.device.known_to_be_dimmable and not event.device.known_to_be_rollershutter + or isinstance(event.device, rfxtrxmod.RfyDevice) ) # Add switch from config file @@ -126,12 +127,14 @@ class RfxtrxSwitch(RfxtrxCommandEntity, SwitchEntity): """Return true if device is on.""" return self._state - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" - self._send_command("turn_on") - self.schedule_update_ha_state() + await self._async_send(self._device.send_on) + self._state = True + self.async_write_ha_state() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - self._send_command("turn_off") - self.schedule_update_ha_state() + await self._async_send(self._device.send_off) + self._state = False + self.async_write_ha_state() diff --git a/homeassistant/components/rfxtrx/translations/en.json b/homeassistant/components/rfxtrx/translations/en.json new file mode 100644 index 00000000000..263b2a9467b --- /dev/null +++ b/homeassistant/components/rfxtrx/translations/en.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": {}, + "step": {} + } +} diff --git a/homeassistant/components/rfxtrx/translations/it.json b/homeassistant/components/rfxtrx/translations/it.json new file mode 100644 index 00000000000..a0ccd718ca4 --- /dev/null +++ b/homeassistant/components/rfxtrx/translations/it.json @@ -0,0 +1,4 @@ +{ + "one": "uno", + "other": "altri" +} \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/pt.json b/homeassistant/components/rfxtrx/translations/pt.json new file mode 100644 index 00000000000..350ef57c286 --- /dev/null +++ b/homeassistant/components/rfxtrx/translations/pt.json @@ -0,0 +1,4 @@ +{ + "one": "Um", + "other": "Outro" +} \ No newline at end of file diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 1a46fd9471c..2627d68e3c3 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -108,7 +108,7 @@ def roku_exception_handler(func): return handler -class RokuDataUpdateCoordinator(DataUpdateCoordinator): +class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Class to manage fetching Roku data.""" def __init__( diff --git a/homeassistant/components/roku/translations/es.json b/homeassistant/components/roku/translations/es.json index 222dd1eddec..78fb2580927 100644 --- a/homeassistant/components/roku/translations/es.json +++ b/homeassistant/components/roku/translations/es.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "El dispositivo Roku ya est\u00e1 configurado", + "already_configured": "El dispositivo ya est\u00e1 configurado", "unknown": "Error inesperado" }, "error": { - "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo." + "cannot_connect": "Fallo al conectar" }, "flow_title": "Roku: {name}", "step": { @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Host o direcci\u00f3n IP" + "host": "Host" }, "description": "Introduce tu informaci\u00f3n de Roku." } diff --git a/homeassistant/components/roomba/translations/es.json b/homeassistant/components/roomba/translations/es.json index 22f1ead1a56..c96508c7944 100644 --- a/homeassistant/components/roomba/translations/es.json +++ b/homeassistant/components/roomba/translations/es.json @@ -9,7 +9,7 @@ "blid": "BLID", "continuous": "Continuo", "delay": "Retardo", - "host": "Nombre del host o direcci\u00f3n IP", + "host": "Host", "password": "Contrase\u00f1a" }, "description": "Actualmente recuperar el BLID y la contrase\u00f1a es un proceso manual. Sigue los pasos descritos en la documentaci\u00f3n en: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", diff --git a/homeassistant/components/samsungtv/translations/es.json b/homeassistant/components/samsungtv/translations/es.json index b8f789420e5..308df08de0d 100644 --- a/homeassistant/components/samsungtv/translations/es.json +++ b/homeassistant/components/samsungtv/translations/es.json @@ -3,19 +3,19 @@ "abort": { "already_configured": "Este televisor Samsung ya est\u00e1 configurado.", "already_in_progress": "La configuraci\u00f3n del televisor Samsung ya est\u00e1 en marcha.", - "auth_missing": "Home Assistant no est\u00e1 autenticado para conectarse a este televisor Samsung.", + "auth_missing": "Home Assistant no est\u00e1 autorizado para conectarse a este televisor Samsung. Revisa la configuraci\u00f3n de tu televisor para autorizar a Home Assistant.", "not_successful": "No se puede conectar a este dispositivo Samsung TV.", "not_supported": "Esta televisi\u00f3n Samsung actualmente no es compatible." }, "flow_title": "Televisor Samsung: {model}", "step": { "confirm": { - "description": "\u00bfDesea configurar el televisor Samsung {model} ? Si nunca conect\u00f3 Home Assistant antes, deber\u00eda ver una ventana emergente en su televisor pidiendo autenticaci\u00f3n. Las configuraciones manuales para este televisor se sobrescribir\u00e1n.", + "description": "\u00bfQuieres configurar la televisi\u00f3n Samsung {model}? Si nunca la has conectado a Home Assistant antes deber\u00edas ver una ventana en tu TV pidiendo autorizaci\u00f3n. Cualquier configuraci\u00f3n manual de esta TV se sobreescribir\u00e1.", "title": "Samsung TV" }, "user": { "data": { - "host": "Host o direcci\u00f3n IP", + "host": "Host", "name": "Nombre" }, "description": "Introduce la informaci\u00f3n de tu televisi\u00f3n Samsung. Si nunca antes te conectaste con Home Assistant, deber\u00edas ver un mensaje en tu televisi\u00f3n pidiendo autorizaci\u00f3n." diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 4119f7e4c6b..3d25e4a34ae 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -122,7 +122,7 @@ class ScrapeSensor(Entity): """Get the latest data from the source and updates the state.""" self.rest.update() if self.rest.data is None: - _LOGGER.error("Unable to retrieve data") + _LOGGER.error("Unable to retrieve data for %s", self.name) return raw_data = BeautifulSoup(self.rest.data, "html.parser") @@ -139,7 +139,7 @@ class ScrapeSensor(Entity): value = tag.text _LOGGER.debug(value) except IndexError: - _LOGGER.error("Unable to extract data from HTML") + _LOGGER.error("Unable to extract data from HTML for %s", self.name) return if self._value_template is not None: diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 95696981cca..e12e2abd312 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -278,11 +278,10 @@ class ScriptEntity(ToggleEntity): attrs = { ATTR_LAST_TRIGGERED: self.script.last_triggered, ATTR_MODE: self.script.script_mode, + ATTR_CUR: self.script.runs, } if self.script.supports_max: attrs[ATTR_MAX] = self.script.max_runs - if self.is_on: - attrs[ATTR_CUR] = self.script.runs if self.script.last_action: attrs[ATTR_LAST_ACTION] = self.script.last_action return attrs diff --git a/homeassistant/components/sense/translations/es.json b/homeassistant/components/sense/translations/es.json index f80a5da1d44..1199823081c 100644 --- a/homeassistant/components/sense/translations/es.json +++ b/homeassistant/components/sense/translations/es.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "email": "Direcci\u00f3n de correo electr\u00f3nico", + "email": "Correo electr\u00f3nico", "password": "Contrase\u00f1a" }, "title": "Conectar a tu Sense Energy Monitor" diff --git a/homeassistant/components/sense/translations/no.json b/homeassistant/components/sense/translations/no.json index fdddc6de82d..c3457ccb280 100644 --- a/homeassistant/components/sense/translations/no.json +++ b/homeassistant/components/sense/translations/no.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "email": "E-postadresse", + "email": "E-post", "password": "Passord" }, "title": "Koble til din Sense Energi Monitor" diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 53b16944cb2..42b198d48d9 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -259,7 +259,7 @@ class SeventeenTrackPackageSensor(Entity): self._friendly_name if self._friendly_name else self._tracking_number ) message = NOTIFICATION_DELIVERED_MESSAGE.format( - self._tracking_number, identification + identification, self._tracking_number ) title = NOTIFICATION_DELIVERED_TITLE.format(identification) notification_id = NOTIFICATION_DELIVERED_TITLE.format(self._tracking_number) diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index dc9fd8769d6..bce980035dc 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -12,6 +12,8 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType DOMAIN = "shell_command" +COMMAND_TIMEOUT = 60 + _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( @@ -74,7 +76,22 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: ) process = await create_process - stdout_data, stderr_data = await process.communicate() + try: + stdout_data, stderr_data = await asyncio.wait_for( + process.communicate(), COMMAND_TIMEOUT + ) + except asyncio.TimeoutError: + _LOGGER.exception( + "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT + ) + if process: + try: + await process.kill() + except TypeError: + pass + del process + + return if stdout_data: _LOGGER.debug( diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index c986add4539..0ec77d13b9a 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,6 +3,6 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==9.2.1"], + "requirements": ["simplisafe-python==9.2.2"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/simplisafe/translations/ca.json b/homeassistant/components/simplisafe/translations/ca.json index 82cedbbca9f..98727fe4c57 100644 --- a/homeassistant/components/simplisafe/translations/ca.json +++ b/homeassistant/components/simplisafe/translations/ca.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "Aquest compte SimpliSafe ja est\u00e0 en \u00fas." + "already_configured": "Aquest compte SimpliSafe ja est\u00e0 en \u00fas.", + "reauth_successful": "Reautenticaci\u00f3 amb SimpliSafe exitosa." }, "error": { "identifier_exists": "Aquest compte ja est\u00e0 registrat", - "invalid_credentials": "Credencials inv\u00e0lides" + "invalid_credentials": "Credencials inv\u00e0lides", + "still_awaiting_mfa": "Esperant clic de l'enlla\u00e7 del correu MFA", + "unknown": "Error inesperat" }, "step": { + "mfa": { + "description": "Consulta el correu i busca-hi un missatge amb un enlla\u00e7 de SimpliSafe. Despr\u00e9s de verificar l'enlla\u00e7, torneu aqu\u00ed per completar la instal\u00b7laci\u00f3 de la integraci\u00f3.", + "title": "Autenticaci\u00f3 multi-factor SimpliSafe" + }, + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "El token d'acc\u00e9s ha caducat o ha estat revocat. Introdueix la teva contrasenya per tornar a vincular el compte.", + "title": "Torna a vincular un compte SimpliSafe" + }, "user": { "data": { "code": "Codi (utilitzat a la UI de Home Assistant)", diff --git a/homeassistant/components/simplisafe/translations/en.json b/homeassistant/components/simplisafe/translations/en.json index 29ad4ee88ef..7e2cf0bf98a 100644 --- a/homeassistant/components/simplisafe/translations/en.json +++ b/homeassistant/components/simplisafe/translations/en.json @@ -8,7 +8,7 @@ "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials", "still_awaiting_mfa": "Still awaiting MFA email click", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "Unexpected error" }, "step": { "mfa": { @@ -17,7 +17,7 @@ }, "reauth_confirm": { "data": { - "password": "[%key:common::config_flow::data::password%]" + "password": "Password" }, "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", "title": "Re-link SimpliSafe Account" @@ -25,8 +25,8 @@ "user": { "data": { "code": "Code (used in Home Assistant UI)", - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::email%]" + "password": "Password", + "username": "Email" }, "title": "Fill in your information." } diff --git a/homeassistant/components/simplisafe/translations/es.json b/homeassistant/components/simplisafe/translations/es.json index 8dbf1248fd6..64000e66d62 100644 --- a/homeassistant/components/simplisafe/translations/es.json +++ b/homeassistant/components/simplisafe/translations/es.json @@ -1,18 +1,32 @@ { "config": { "abort": { - "already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso." + "already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso.", + "reauth_successful": "SimpliSafe se ha reautenticado correctamente." }, "error": { "identifier_exists": "Cuenta ya registrada", - "invalid_credentials": "Credenciales no v\u00e1lidas" + "invalid_credentials": "Credenciales no v\u00e1lidas", + "still_awaiting_mfa": "Esperando todav\u00eda el clic en el correo electr\u00f3nico de MFA", + "unknown": "Error inesperado" }, "step": { + "mfa": { + "description": "Comprueba tu correo electr\u00f3nico para obtener un enlace desde SimpliSafe. Despu\u00e9s de verificar el enlace, vulve aqu\u00ed para completar la instalaci\u00f3n de la integraci\u00f3n.", + "title": "Autenticaci\u00f3n Multi-Factor SimpliSafe" + }, + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + }, + "description": "Tu token de acceso ha expirado o ha sido revocado. Introduce tu contrase\u00f1a para volver a vincular tu cuenta.", + "title": "Volver a vincular la Cuenta SimpliSafe" + }, "user": { "data": { "code": "C\u00f3digo (utilizado en el interfaz de usuario de Home Assistant)", "password": "Contrase\u00f1a", - "username": "Direcci\u00f3n de correo electr\u00f3nico" + "username": "Correo electr\u00f3nico" }, "title": "Introduce tu informaci\u00f3n" } diff --git a/homeassistant/components/simplisafe/translations/it.json b/homeassistant/components/simplisafe/translations/it.json index c970cd6d48b..2d293ab1bb3 100644 --- a/homeassistant/components/simplisafe/translations/it.json +++ b/homeassistant/components/simplisafe/translations/it.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "Questo account SimpliSafe \u00e8 gi\u00e0 in uso." + "already_configured": "Questo account SimpliSafe \u00e8 gi\u00e0 in uso.", + "reauth_successful": "SimpliSafe riautenticato correttamente." }, "error": { "identifier_exists": "Account gi\u00e0 registrato", - "invalid_credentials": "Credenziali non valide" + "invalid_credentials": "Credenziali non valide", + "still_awaiting_mfa": "Ancora in attesa del clic sull'email MFA", + "unknown": "Errore imprevisto" }, "step": { + "mfa": { + "description": "Controlla la tua e-mail per trovare un link da SimpliSafe. Dopo aver verificato il link, torna qui per completare l'installazione dell'integrazione.", + "title": "Autenticazione a pi\u00f9 fattori (MFA) SimpliSafe " + }, + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Il token di accesso \u00e8 scaduto o \u00e8 stato revocato. Inserisci la tua password per ricollegare il tuo account.", + "title": "Ricollegare l'account SimpliSafe" + }, "user": { "data": { "code": "Codice (utilizzato nell'Interfaccia Utente di Home Assistant)", diff --git a/homeassistant/components/simplisafe/translations/lb.json b/homeassistant/components/simplisafe/translations/lb.json index e6e2f760a8d..d81ddd63f0b 100644 --- a/homeassistant/components/simplisafe/translations/lb.json +++ b/homeassistant/components/simplisafe/translations/lb.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "D\u00ebse SimpliSafe Kont g\u00ebtt scho benotzt." + "already_configured": "D\u00ebse SimpliSafe Kont g\u00ebtt scho benotzt.", + "reauth_successful": "SimpliSafe erfollegr\u00e4ich re-authentifiz\u00e9iert." }, "error": { "identifier_exists": "Konto ass scho registr\u00e9iert", - "invalid_credentials": "Ong\u00eblteg Login Informatioune" + "invalid_credentials": "Ong\u00eblteg Login Informatioune", + "still_awaiting_mfa": "Waart nach den MFA E-Mail Klick.", + "unknown": "Onerwaarte Feeler" }, "step": { + "mfa": { + "description": "Kuck den E-Mailen fir ee Link vun SimpliSafe. Nodeem de Link opgeruff gouf, komm heihinner zer\u00e9ck fir d'Installatioun vun der Integratioun ofzeschl\u00e9issen.", + "title": "SimpliSafe Multi-Faktor Authentifikatioun" + }, + "reauth_confirm": { + "data": { + "password": "Passwuert" + }, + "description": "D\u00e4in Acc\u00e8s Jeton as ofgelaf oder gouf revok\u00e9iert. G\u00ebff d\u00e4i Passwuert an fir d\u00e4i Kont fr\u00ebsch ze verbannen.", + "title": "SimpliSafe Kont fr\u00ebsch verbannen" + }, "user": { "data": { "code": "Code (benotzt am Home Assistant Benotzer Interface)", diff --git a/homeassistant/components/simplisafe/translations/no.json b/homeassistant/components/simplisafe/translations/no.json index 3a253f2e5a1..664bb912528 100644 --- a/homeassistant/components/simplisafe/translations/no.json +++ b/homeassistant/components/simplisafe/translations/no.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "Denne SimpliSafe-kontoen er allerede i bruk." + "already_configured": "Denne SimpliSafe-kontoen er allerede i bruk.", + "reauth_successful": "SimpliSafe gjenautentisering er vellykket." }, "error": { "identifier_exists": "Konto er allerede registrert", - "invalid_credentials": "Ugyldig legitimasjon" + "invalid_credentials": "Ugyldig legitimasjon", + "still_awaiting_mfa": "Forventer fortsatt MFA-e-postklikk", + "unknown": "Uventet feil" }, "step": { + "mfa": { + "description": "Sjekk e-posten din for en lenke fra SimpliSafe. Etter \u00e5 ha bekreftet lenken, g\u00e5 tilbake hit for \u00e5 fullf\u00f8re installasjonen av integrasjonen.", + "title": "SimpliSafe Multi-Factor Autentisering" + }, + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Adgangstokenet ditt har utl\u00f8pt eller blitt opphevet. Skriv inn passordet ditt for \u00e5 koble kontoen din p\u00e5 nytt.", + "title": "Koble SimpliSafe-kontoen p\u00e5 nytt" + }, "user": { "data": { "code": "Kode (brukt i Home Assistant brukergrensesnittet)", diff --git a/homeassistant/components/simplisafe/translations/ru.json b/homeassistant/components/simplisafe/translations/ru.json index 26665617b1d..cd539ae184c 100644 --- a/homeassistant/components/simplisafe/translations/ru.json +++ b/homeassistant/components/simplisafe/translations/ru.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430.", + "reauth_successful": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", - "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "still_awaiting_mfa": "\u041e\u0436\u0438\u0434\u0430\u043d\u0438\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f, \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u043f\u043e \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u0435.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "step": { + "mfa": { + "description": "\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0441\u0432\u043e\u044e \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0443\u044e \u043f\u043e\u0447\u0442\u0443 \u043d\u0430 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 \u0441\u0441\u044b\u043b\u043a\u0438 \u043e\u0442 SimpliSafe. \u041f\u043e\u0441\u043b\u0435 \u0442\u043e\u0433\u043e \u043a\u0430\u043a \u043e\u0442\u043a\u0440\u043e\u0435\u0442\u0435 \u0441\u0441\u044b\u043b\u043a\u0443, \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438.", + "title": "\u0414\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f SimpliSafe" + }, + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0430\u0448 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438\u0441\u0442\u0435\u043a \u0438\u043b\u0438 \u0431\u044b\u043b \u0430\u043d\u043d\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u043d\u043e\u0432\u043e \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0430 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430 SimpliSafe" + }, "user": { "data": { "code": "\u041a\u043e\u0434 (\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u0432 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0435 Home Assistant)", diff --git a/homeassistant/components/simplisafe/translations/uk.json b/homeassistant/components/simplisafe/translations/uk.json index c7938df009e..376fb4468db 100644 --- a/homeassistant/components/simplisafe/translations/uk.json +++ b/homeassistant/components/simplisafe/translations/uk.json @@ -1,6 +1,11 @@ { "config": { "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c", diff --git a/homeassistant/components/simplisafe/translations/zh-Hant.json b/homeassistant/components/simplisafe/translations/zh-Hant.json index 2c522045cab..4b195cc5466 100644 --- a/homeassistant/components/simplisafe/translations/zh-Hant.json +++ b/homeassistant/components/simplisafe/translations/zh-Hant.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "\u6b64 SimpliSafe \u5e33\u865f\u5df2\u88ab\u4f7f\u7528\u3002" + "already_configured": "\u6b64 SimpliSafe \u5e33\u865f\u5df2\u88ab\u4f7f\u7528\u3002", + "reauth_successful": "SimpliSafe \u5df2\u6210\u529f\u8a8d\u8b49\u3002" }, "error": { "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", - "invalid_credentials": "\u6191\u8b49\u7121\u6548" + "invalid_credentials": "\u6191\u8b49\u7121\u6548", + "still_awaiting_mfa": "\u4ecd\u5728\u7b49\u5019\u9ede\u64ca\u591a\u6b65\u9a5f\u8a8d\u8b49\u90f5\u4ef6", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { + "mfa": { + "description": "\u8acb\u6aa2\u67e5\u4f86\u81ea SimpliSafe \u7684\u90f5\u4ef6\u4ee5\u53d6\u5f97\u9023\u7d50\u3002\u78ba\u8a8d\u9023\u7d50\u5f8c\uff0c\u518d\u56de\u5230\u6b64\u8655\u4ee5\u5b8c\u6210\u6574\u5408\u5b89\u88dd\u3002", + "title": "SimpliSafe \u591a\u6b65\u9a5f\u9a57\u8b49" + }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u5b58\u53d6\u5bc6\u9470\u5df2\u7d93\u904e\u671f\u6216\u53d6\u6d88\uff0c\u8acb\u8f38\u5165\u5bc6\u78bc\u4ee5\u91cd\u65b0\u9023\u7d50\u5e33\u865f\u3002", + "title": "\u91cd\u65b0\u9023\u7d50 SimpliSafe \u5e33\u865f" + }, "user": { "data": { "code": "\u9a57\u8b49\u78bc\uff08\u4f7f\u7528\u65bc Home Assistant UI\uff09", diff --git a/homeassistant/components/sky_hub/__init__.py b/homeassistant/components/sky_hub/__init__.py index a5b8969018f..9c875507c09 100644 --- a/homeassistant/components/sky_hub/__init__.py +++ b/homeassistant/components/sky_hub/__init__.py @@ -1 +1,6 @@ """The sky_hub component.""" + + +async def async_setup(hass, config): + """Set up the sky_hub component.""" + return True diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index 2537196f21d..b97331b6195 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -1,8 +1,7 @@ """Support for Sky Hub.""" import logging -import re -import requests +from pyskyqhub.skyq_hub import SkyQHub import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -10,102 +9,54 @@ from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, DeviceScanner, ) -from homeassistant.const import CONF_HOST, HTTP_OK +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -_MAC_REGEX = re.compile(r"(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})") PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_HOST): cv.string}) -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Return a Sky Hub scanner if successful.""" - scanner = SkyHubDeviceScanner(config[DOMAIN]) + host = config[DOMAIN].get(CONF_HOST, "192.168.1.254") + websession = async_get_clientsession(hass) + hub = SkyQHub(websession, host) - return scanner if scanner.success_init else None + _LOGGER.debug("Initialising Sky Hub") + await hub.async_connect() + if hub.success_init: + scanner = SkyHubDeviceScanner(hub) + return scanner + + return None class SkyHubDeviceScanner(DeviceScanner): """This class queries a Sky Hub router.""" - def __init__(self, config): + def __init__(self, hub): """Initialise the scanner.""" - _LOGGER.info("Initialising Sky Hub") - self.host = config.get(CONF_HOST, "192.168.1.254") + self._hub = hub self.last_results = {} - self.url = f"http://{self.host}/" - # Test the router is accessible - data = _get_skyhub_data(self.url) - self.success_init = data is not None - - def scan_devices(self): + async def async_scan_devices(self): """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return (device 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.""" - # If not initialised and not already scanned and not found. - if device not in self.last_results: - self._update_info() - - if not self.last_results: - return None + await self._async_update_info() + return list(self.last_results) + async def async_get_device_name(self, device): + """Return the name of the given device.""" return self.last_results.get(device) - def _update_info(self): - """Ensure the information from the Sky Hub is up to date. + async def _async_update_info(self): + """Ensure the information from the Sky Hub is up to date.""" + _LOGGER.debug("Scanning") - Return boolean if scanning successful. - """ - if not self.success_init: - return False - - _LOGGER.info("Scanning") - - data = _get_skyhub_data(self.url) + data = await self._hub.async_get_skyhub_data() if not data: - _LOGGER.warning("Error scanning devices") - return False + return self.last_results = data - - return True - - -def _get_skyhub_data(url): - """Retrieve data from Sky Hub and return parsed result.""" - try: - response = requests.get(url, timeout=5) - except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") - return - if response.status_code == HTTP_OK: - return _parse_skyhub_response(response.text) - _LOGGER.error("Invalid response from Sky Hub: %s", response) - - -def _parse_skyhub_response(data_str): - """Parse the Sky Hub data format.""" - pattmatch = re.search("attach_dev = '(.*)'", data_str) - if pattmatch is None: - raise OSError( - "Error: Impossible to fetch data from Sky Hub. Try to reboot the router." - ) - patt = pattmatch.group(1) - - dev = [patt1.split(",") for patt1 in patt.split("")] - - devices = {} - for dvc in dev: - if _MAC_REGEX.match(dvc[1]): - devices[dvc[1]] = dvc[0] - else: - raise RuntimeError(f"Error: MAC address {dvc[1]} not in correct format.") - - return devices diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json index b358fa76fbf..e663820a5ef 100644 --- a/homeassistant/components/sky_hub/manifest.json +++ b/homeassistant/components/sky_hub/manifest.json @@ -2,5 +2,6 @@ "domain": "sky_hub", "name": "Sky Hub", "documentation": "https://www.home-assistant.io/integrations/sky_hub", - "codeowners": [] + "requirements": ["pyskyqhub==0.1.1"], + "codeowners": ["@rogerselwyn"] } diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index b7f3d81feb0..d5c784398cf 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -24,7 +24,6 @@ import homeassistant.helpers.template as template _LOGGER = logging.getLogger(__name__) -ATTR_ATTACHMENTS = "attachments" ATTR_BLOCKS = "blocks" ATTR_BLOCKS_TEMPLATE = "blocks_template" ATTR_FILE = "file" @@ -52,11 +51,7 @@ DATA_FILE_SCHEMA = vol.Schema( ) DATA_TEXT_ONLY_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ATTACHMENTS): list, - vol.Optional(ATTR_BLOCKS): list, - vol.Optional(ATTR_BLOCKS_TEMPLATE): list, - } + {vol.Optional(ATTR_BLOCKS): list, vol.Optional(ATTR_BLOCKS_TEMPLATE): list} ) DATA_SCHEMA = vol.All( @@ -196,15 +191,12 @@ class SlackNotificationService(BaseNotificationService): except ClientError as err: _LOGGER.error("Error while uploading file message: %s", err) - async def _async_send_text_only_message( - self, targets, message, title, attachments, blocks - ): + async def _async_send_text_only_message(self, targets, message, title, blocks): """Send a text-only message.""" tasks = { target: self._client.chat_postMessage( channel=target, text=message, - attachments=attachments, blocks=blocks, icon_emoji=self._icon, link_names=True, @@ -242,15 +234,6 @@ class SlackNotificationService(BaseNotificationService): # Message Type 1: A text-only message if ATTR_FILE not in data: - attachments = data.get(ATTR_ATTACHMENTS, {}) - if attachments: - _LOGGER.warning( - "Attachments are deprecated and part of Slack's legacy API; " - "support for them will be dropped in 0.114.0. In most cases, " - "Blocks should be used instead: " - "https://www.home-assistant.io/integrations/slack/" - ) - if ATTR_BLOCKS_TEMPLATE in data: blocks = _async_templatize_blocks(self.hass, data[ATTR_BLOCKS_TEMPLATE]) elif ATTR_BLOCKS in data: @@ -259,7 +242,7 @@ class SlackNotificationService(BaseNotificationService): blocks = {} return await self._async_send_text_only_message( - targets, message, title, attachments, blocks + targets, message, title, blocks ) # Message Type 2: A message that uploads a remote file diff --git a/homeassistant/components/smappee/translations/no.json b/homeassistant/components/smappee/translations/no.json index 6b2141fd61e..a6ef71b7448 100644 --- a/homeassistant/components/smappee/translations/no.json +++ b/homeassistant/components/smappee/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.", - "missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen." + "missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "step": { "pick_implementation": { diff --git a/homeassistant/components/smarthab/translations/lb.json b/homeassistant/components/smarthab/translations/lb.json new file mode 100644 index 00000000000..d2892805fac --- /dev/null +++ b/homeassistant/components/smarthab/translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "service": "Feeler beim verbanne mat SmartHab. De Service ass viellaicht net ereechbar. Iwwerpr\u00e9if deng Verbindung.", + "unknown_error": "Onerwaarte Feeler", + "wrong_login": "Ong\u00eblteg Authentifikatioun" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwuert" + }, + "description": "W\u00e9inst technesche Gr\u00ebnn soll een zweeten Kont benotz gin fir d\u00e4in Home Assistant. Du kanns een zous\u00e4tzleche Kont an der SmartHab Applikatioun erstellen.", + "title": "SmartHab ariichten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/no.json b/homeassistant/components/smarthab/translations/no.json new file mode 100644 index 00000000000..de1e50f5c4b --- /dev/null +++ b/homeassistant/components/smarthab/translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "service": "Feil under fors\u00f8k p\u00e5 \u00e5 n\u00e5 SmartHab. Tjenesten kan v\u00e6re nede. Sjekk tilkoblingen din.", + "unknown_error": "Uventet feil", + "wrong_login": "Ugyldig godkjenning" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "description": "Av tekniske \u00e5rsaker m\u00e5 du s\u00f8rge for \u00e5 bruke en sekund\u00e6r konto som er spesifikk for oppsettet i Home Assistant. Du kan opprette en fra SmartHab-programmet.", + "title": "Oppsett av SmartHab" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/pl.json b/homeassistant/components/smarthab/translations/pl.json index 09cd3af488a..7279eb6ca79 100644 --- a/homeassistant/components/smarthab/translations/pl.json +++ b/homeassistant/components/smarthab/translations/pl.json @@ -2,13 +2,14 @@ "config": { "error": { "service": "B\u0142\u0105d podczas pr\u00f3by osi\u0105gni\u0119cia SmartHab. Us\u0142uga mo\u017ce by\u0107 wy\u0142\u0105czna. Sprawd\u017a po\u0142\u0105czenie.", - "unknown_error": "Niespodziewany b\u0142\u0105d", - "wrong_login": "Niepoprawna autoryzacja" + "unknown_error": "Nieoczekiwany b\u0142\u0105d.", + "wrong_login": "Niepoprawne uwierzytelnienie." }, "step": { "user": { "data": { - "password": "Has\u0142o" + "email": "Adres e-mail", + "password": "[%key_id:common::config_flow::data::password%]" }, "title": "Konfiguracja SmartHab" } diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 9d779bf9d5b..03188411f07 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -24,6 +24,8 @@ SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_" SETTINGS_INSTANCE_ID = "hassInstanceId" +SUBSCRIPTION_WARNING_LIMIT = 40 + STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -41,6 +43,12 @@ SUPPORTED_PLATFORMS = [ "scene", ] +IGNORED_CAPABILITIES = [ + "execute", + "healthCheck", + "ocf", +] + TOKEN_REFRESH_INTERVAL = timedelta(days=14) VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index bf137ae398d..58ea833cb7d 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -3,7 +3,7 @@ "name": "SmartThings", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smartthings", - "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.2"], + "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.3"], "dependencies": ["webhook"], "after_dependencies": ["cloud"], "codeowners": ["@andrewsayre"] diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 918ee455c27..24d6e4ae18f 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -44,10 +44,12 @@ from .const import ( DATA_BROKERS, DATA_MANAGER, DOMAIN, + IGNORED_CAPABILITIES, SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION, + SUBSCRIPTION_WARNING_LIMIT, ) _LOGGER = logging.getLogger(__name__) @@ -355,7 +357,26 @@ async def smartapp_sync_subscriptions( capabilities = set() for device in devices: capabilities.update(device.capabilities) + # Remove items not defined in the library capabilities.intersection_update(CAPABILITIES) + # Remove unused capabilities + capabilities.difference_update(IGNORED_CAPABILITIES) + capability_count = len(capabilities) + if capability_count > SUBSCRIPTION_WARNING_LIMIT: + _LOGGER.warning( + "Some device attributes may not receive push updates and there may be subscription " + "creation failures under app '%s' because %s subscriptions are required but " + "there is a limit of %s per app", + installed_app_id, + capability_count, + SUBSCRIPTION_WARNING_LIMIT, + ) + _LOGGER.debug( + "Synchronizing subscriptions for %s capabilities under app '%s': %s", + capability_count, + installed_app_id, + capabilities, + ) # Get current subscriptions and find differences subscriptions = await api.subscriptions(installed_app_id) diff --git a/homeassistant/components/smartthings/translations/no.json b/homeassistant/components/smartthings/translations/no.json index c6945783c11..75093b4a591 100644 --- a/homeassistant/components/smartthings/translations/no.json +++ b/homeassistant/components/smartthings/translations/no.json @@ -27,7 +27,7 @@ "location_id": "Lokasjon" }, "description": "Vennligst velg SmartThings lokasjon du vil legge til Home Assistant. Vi \u00e5pner deretter et nytt vindu og ber deg om \u00e5 logge inn og godkjenne installasjon av Home Assistant-integrasjonen p\u00e5 det valgte stedet.", - "title": "Velg Posisjon" + "title": "Velg beliggenhet" }, "user": { "description": "SmartThings konfigureres til \u00e5 sende push-oppdateringer til Home Assistant p\u00e5:\n\" {webhook_url}\n\nHvis dette ikke er riktig, m\u00e5 du oppdatere konfigurasjonen, starte Home Assistant p\u00e5 nytt og pr\u00f8ve p\u00e5 nytt.", diff --git a/homeassistant/components/sms/translations/no.json b/homeassistant/components/sms/translations/no.json index 98af331c1dd..7398a7b7b0a 100644 --- a/homeassistant/components/sms/translations/no.json +++ b/homeassistant/components/sms/translations/no.json @@ -1,5 +1,13 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/sms/translations/pl.json b/homeassistant/components/sms/translations/pl.json new file mode 100644 index 00000000000..eec34cc0197 --- /dev/null +++ b/homeassistant/components/sms/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "device": "Urz\u0105dzenie" + }, + "title": "Po\u0142\u0105cz z modemem" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/translations/es.json b/homeassistant/components/solaredge/translations/es.json index f3a97d29f50..7a8b55fc649 100644 --- a/homeassistant/components/solaredge/translations/es.json +++ b/homeassistant/components/solaredge/translations/es.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "api_key": "La clave de la API para este sitio", + "api_key": "Clave API", "name": "El nombre de esta instalaci\u00f3n", "site_id": "La identificaci\u00f3n del sitio de SolarEdge" }, diff --git a/homeassistant/components/soma/translations/no.json b/homeassistant/components/soma/translations/no.json index 4b9fe3b564d..5c2c01ca7a6 100644 --- a/homeassistant/components/soma/translations/no.json +++ b/homeassistant/components/soma/translations/no.json @@ -14,7 +14,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "description": "Vennligst fyll inn tilkoblingsinnstillingene for din SOMA Connect.", "title": "" diff --git a/homeassistant/components/sonarr/translations/no.json b/homeassistant/components/sonarr/translations/no.json index 0b98c67d820..694565b72d6 100644 --- a/homeassistant/components/sonarr/translations/no.json +++ b/homeassistant/components/sonarr/translations/no.json @@ -1,5 +1,13 @@ { "config": { + "abort": { + "already_configured": "Tjenesten er allerede konfigurert", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning" + }, "flow_title": "", "step": { "user": { diff --git a/homeassistant/components/songpal/translations/no.json b/homeassistant/components/songpal/translations/no.json index 4c3ef9e6c0d..eb07eeb2daa 100644 --- a/homeassistant/components/songpal/translations/no.json +++ b/homeassistant/components/songpal/translations/no.json @@ -1,8 +1,12 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "not_songpal_device": "Ikke en Songpal-enhet" }, + "error": { + "cannot_connect": "Tilkobling mislyktes." + }, "flow_title": "", "step": { "init": { diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py index 125799b394a..f2e9a06fb94 100644 --- a/homeassistant/components/spider/__init__.py +++ b/homeassistant/components/spider/__init__.py @@ -1,29 +1,27 @@ """Support for Spider Smart devices.""" -from datetime import timedelta +import asyncio import logging from spiderpy.spiderapi import SpiderApi, UnauthorizedException import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -DOMAIN = "spider" - -SPIDER_COMPONENTS = ["climate", "switch"] - -SCAN_INTERVAL = timedelta(seconds=120) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, } ) }, @@ -31,27 +29,66 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config): - """Set up Spider Component.""" +def _spider_startup_wrapper(entry): + """Startup wrapper for spider.""" + api = SpiderApi( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_SCAN_INTERVAL], + ) + return api - username = config[DOMAIN][CONF_USERNAME] - password = config[DOMAIN][CONF_PASSWORD] - refresh_rate = config[DOMAIN][CONF_SCAN_INTERVAL] - try: - api = SpiderApi(username, password, refresh_rate.total_seconds()) - - hass.data[DOMAIN] = { - "controller": api, - "thermostats": api.get_thermostats(), - "power_plugs": api.get_power_plugs(), - } - - for component in SPIDER_COMPONENTS: - load_platform(hass, component, DOMAIN, {}, config) - - _LOGGER.debug("Connection with Spider API succeeded") +async def async_setup(hass, config): + """Set up a config entry.""" + hass.data[DOMAIN] = {} + if DOMAIN not in config: return True + + conf = config[DOMAIN] + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up Spider via config entry.""" + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + _spider_startup_wrapper, entry + ) except UnauthorizedException: _LOGGER.error("Can't connect to the Spider API") return False + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload Spider entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if not unload_ok: + return False + + hass.data[DOMAIN].pop(entry.entry_id) + + return True diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index 78c77f3679a..015606286e2 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from . import DOMAIN as SPIDER_DOMAIN +from .const import DOMAIN SUPPORT_FAN = ["Auto", "Low", "Medium", "High", "Boost 10", "Boost 20", "Boost 30"] @@ -29,16 +29,13 @@ SPIDER_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_SPIDER.items()} _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Spider thermostat.""" - if discovery_info is None: - return +async def async_setup_entry(hass, config, async_add_entities): + """Initialize a Spider thermostat.""" + api = hass.data[DOMAIN][config.entry_id] - devices = [ - SpiderThermostat(hass.data[SPIDER_DOMAIN]["controller"], device) - for device in hass.data[SPIDER_DOMAIN]["thermostats"] - ] - add_entities(devices, True) + entities = [SpiderThermostat(api, entity) for entity in api.get_thermostats()] + + async_add_entities(entities) class SpiderThermostat(ClimateEntity): diff --git a/homeassistant/components/spider/config_flow.py b/homeassistant/components/spider/config_flow.py new file mode 100644 index 00000000000..e1026f344b0 --- /dev/null +++ b/homeassistant/components/spider/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for Spider.""" +import logging + +from spiderpy.spiderapi import SpiderApi, SpiderApiException, UnauthorizedException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA_USER = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + +RESULT_AUTH_FAILED = "auth_failed" +RESULT_CONN_ERROR = "conn_error" +RESULT_SUCCESS = "success" + + +class SpiderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Spider config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the Spider flow.""" + self.data = { + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + } + + def _try_connect(self): + """Try to connect and check auth.""" + try: + SpiderApi( + self.data[CONF_USERNAME], + self.data[CONF_PASSWORD], + self.data[CONF_SCAN_INTERVAL], + ) + except SpiderApiException: + return RESULT_CONN_ERROR + except UnauthorizedException: + return RESULT_AUTH_FAILED + + return RESULT_SUCCESS + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + if user_input is not None: + self.data[CONF_USERNAME] = user_input["username"] + self.data[CONF_PASSWORD] = user_input["password"] + + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_SUCCESS: + return self.async_create_entry(title=DOMAIN, data=self.data,) + if result != RESULT_AUTH_FAILED: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return self.async_abort(reason=result) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors, + ) + + async def async_step_import(self, import_data): + """Import spider config from configuration.yaml.""" + return await self.async_step_user(import_data) diff --git a/homeassistant/components/spider/const.py b/homeassistant/components/spider/const.py new file mode 100644 index 00000000000..420767fd221 --- /dev/null +++ b/homeassistant/components/spider/const.py @@ -0,0 +1,6 @@ +"""Constants for the Spider integration.""" + +DOMAIN = "spider" +DEFAULT_SCAN_INTERVAL = 300 + +PLATFORMS = ["climate", "switch"] diff --git a/homeassistant/components/spider/manifest.json b/homeassistant/components/spider/manifest.json index 8fa108f24f7..b285cafcfa9 100644 --- a/homeassistant/components/spider/manifest.json +++ b/homeassistant/components/spider/manifest.json @@ -2,6 +2,11 @@ "domain": "spider", "name": "Itho Daalderop Spider", "documentation": "https://www.home-assistant.io/integrations/spider", - "requirements": ["spiderpy==1.3.1"], - "codeowners": ["@peternijssen"] + "requirements": [ + "spiderpy==1.3.1" + ], + "codeowners": [ + "@peternijssen" + ], + "config_flow": true } diff --git a/homeassistant/components/spider/strings.json b/homeassistant/components/spider/strings.json new file mode 100644 index 00000000000..2e86f47dd2d --- /dev/null +++ b/homeassistant/components/spider/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "title": "Sign-in with mijn.ithodaalderop.nl account", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py index 58a45cf7b4d..cea20d8c6be 100644 --- a/homeassistant/components/spider/switch.py +++ b/homeassistant/components/spider/switch.py @@ -3,22 +3,18 @@ import logging from homeassistant.components.switch import SwitchEntity -from . import DOMAIN as SPIDER_DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Spider thermostat.""" - if discovery_info is None: - return +async def async_setup_entry(hass, config, async_add_entities): + """Initialize a Spider thermostat.""" + api = hass.data[DOMAIN][config.entry_id] - devices = [ - SpiderPowerPlug(hass.data[SPIDER_DOMAIN]["controller"], device) - for device in hass.data[SPIDER_DOMAIN]["power_plugs"] - ] + entities = [SpiderPowerPlug(api, entity) for entity in api.get_power_plugs()] - add_entities(devices, True) + async_add_entities(entities) class SpiderPowerPlug(SwitchEntity): diff --git a/homeassistant/components/spider/translations/en.json b/homeassistant/components/spider/translations/en.json new file mode 100644 index 00000000000..b33c05419a3 --- /dev/null +++ b/homeassistant/components/spider/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Sign-in with mijn.ithodaalderop.nl account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/squeezebox/translations/no.json b/homeassistant/components/squeezebox/translations/no.json index 17abd4c683b..ddda0b61be2 100644 --- a/homeassistant/components/squeezebox/translations/no.json +++ b/homeassistant/components/squeezebox/translations/no.json @@ -1,20 +1,30 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "no_server_found": "Ingen LMS-server funnet." }, "error": { - "no_server_found": "Kan ikke automatisk oppdage serveren." + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "no_server_found": "Kan ikke automatisk oppdage serveren.", + "unknown": "Uventet feil" }, "flow_title": "", "step": { "edit": { "data": { - "port": "" + "host": "Vert", + "password": "Passord", + "port": "", + "username": "Brukernavn" }, "title": "Redigere tilkoblingsinformasjon" }, "user": { + "data": { + "host": "Vert" + }, "title": "Konfigurer Logitech Media Server" } } diff --git a/homeassistant/components/squeezebox/translations/pl.json b/homeassistant/components/squeezebox/translations/pl.json index a7f144f59e9..a4339711918 100644 --- a/homeassistant/components/squeezebox/translations/pl.json +++ b/homeassistant/components/squeezebox/translations/pl.json @@ -1,10 +1,25 @@ { "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": { "edit": { "data": { - "password": "Has\u0142o", - "port": "Port" + "host": "Nazwa hosta lub adres IP", + "password": "[%key_id:common::config_flow::data::password%]", + "port": "Port", + "username": "[%key_id:common::config_flow::data::username%]" + } + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" } } } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 94e256f0523..555d68cd5d4 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -7,8 +7,9 @@ import aiohttp from defusedxml import ElementTree from netdisco import ssdp, util -from homeassistant.generated.ssdp import SSDP +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.helpers.event import async_track_time_interval +from homeassistant.loader import async_get_ssdp DOMAIN = "ssdp" SCAN_INTERVAL = timedelta(seconds=60) @@ -33,12 +34,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass, config): """Set up the SSDP integration.""" - async def initialize(): - scanner = Scanner(hass) + async def initialize(_): + scanner = Scanner(hass, await async_get_ssdp(hass)) await scanner.async_scan(None) async_track_time_interval(hass, scanner.async_scan, SCAN_INTERVAL) - hass.loop.create_task(initialize()) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, initialize) return True @@ -46,10 +47,11 @@ async def async_setup(hass, config): class Scanner: """Class to manage SSDP scanning.""" - def __init__(self, hass): + def __init__(self, hass, integration_matchers): """Initialize class.""" self.hass = hass self.seen = set() + self._integration_matchers = integration_matchers self._description_cache = {} async def async_scan(self, _): @@ -120,7 +122,7 @@ class Scanner: info.update(await info_req) domains = set() - for domain, matchers in SSDP.items(): + for domain, matchers in self._integration_matchers.items(): for matcher in matchers: if all(info.get(k) == v for (k, v) in matcher.items()): domains.add(domain) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 3df7f8fc151..965f2611ed4 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -174,5 +174,6 @@ def stream_worker(hass, stream, quit_event): buffer.output.mux(packet) # Close stream - buffer.output.close() + for buffer in outputs.values(): + buffer.output.close() container.close() diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index c4692598447..fe89413f4d5 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -100,7 +100,10 @@ class Sun(Entity): self._next_change = None def update_location(_event): - self.location = get_astral_location(self.hass) + location = get_astral_location(self.hass) + if location == self.location: + return + self.location = location self.update_events(dt_util.utcnow()) update_location(None) diff --git a/homeassistant/components/syncthru/translations/it.json b/homeassistant/components/syncthru/translations/it.json new file mode 100644 index 00000000000..d8d0b70a8ed --- /dev/null +++ b/homeassistant/components/syncthru/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "invalid_url": "URL non valido", + "syncthru_not_supported": "Il dispositivo non supporta SyncThru", + "unknown_state": "Stato della stampante sconosciuto, verificare l'URL e la connettivit\u00e0 di rete" + }, + "flow_title": "Stampante Samsung SyncThru: {name}", + "step": { + "confirm": { + "data": { + "name": "Nome", + "url": "URL interfaccia Web" + } + }, + "user": { + "data": { + "name": "Nome", + "url": "URL interfaccia Web" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/lb.json b/homeassistant/components/syncthru/translations/lb.json new file mode 100644 index 00000000000..b67031fdcf8 --- /dev/null +++ b/homeassistant/components/syncthru/translations/lb.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "invalid_url": "Ong\u00eblteg URL", + "syncthru_not_supported": "Apparat \u00ebnnerst\u00ebtzt kee SyncThru", + "unknown_state": "Printer Status onbekannt, iwwerpr\u00e9if URL an Netzwierk konnektivit\u00e9it." + }, + "flow_title": "Samsung SyncThru Printer: {name}", + "step": { + "confirm": { + "data": { + "name": "Numm", + "url": "Web interface URL" + } + }, + "user": { + "data": { + "name": "Numm", + "url": "Web interface URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/no.json b/homeassistant/components/syncthru/translations/no.json index 01ea5b65fb1..db24ef5abc7 100644 --- a/homeassistant/components/syncthru/translations/no.json +++ b/homeassistant/components/syncthru/translations/no.json @@ -1,9 +1,25 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "invalid_url": "Ugyldig URL-adresse", + "syncthru_not_supported": "Enheten st\u00f8tter ikke SyncThru", + "unknown_state": "Skrivertilstand ukjent, kontroller URL-adresse og nettverkstilkobling" + }, + "flow_title": "Samsung SyncThru-skriver: {name}", "step": { + "confirm": { + "data": { + "name": "Navn", + "url": "URL-adresse for webgrensesnitt" + } + }, "user": { "data": { - "name": "Navn" + "name": "Navn", + "url": "URL-adresse for webgrensesnitt" } } } diff --git a/homeassistant/components/syncthru/translations/pl.json b/homeassistant/components/syncthru/translations/pl.json index 63dea2d9184..bd174d000c8 100644 --- a/homeassistant/components/syncthru/translations/pl.json +++ b/homeassistant/components/syncthru/translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, "error": { "invalid_url": "Nieprawid\u0142owy URL", "unknown_state": "Nieznany stan drukarki, sprawd\u017a adres URL i \u0142\u0105czno\u015b\u0107 sieciow\u0105" diff --git a/homeassistant/components/syncthru/translations/pt.json b/homeassistant/components/syncthru/translations/pt.json new file mode 100644 index 00000000000..03ddb9523ec --- /dev/null +++ b/homeassistant/components/syncthru/translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "unknown_state": "Estado da impressora desconhecido, verifique a conectividade de URL e de rede" + }, + "step": { + "confirm": { + "data": { + "name": "Nome" + } + }, + "user": { + "data": { + "name": "Nome" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/sl.json b/homeassistant/components/syncthru/translations/sl.json new file mode 100644 index 00000000000..e3f8ff64875 --- /dev/null +++ b/homeassistant/components/syncthru/translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "invalid_url": "Neveljaven URL", + "syncthru_not_supported": "Naprava ne podpira SyncThru", + "unknown_state": "Stanje tiskalnika ni znano, preverite URL in omre\u017eno povezljivost" + }, + "flow_title": "Samsung SyncThru Tiskalnik: {name}", + "step": { + "confirm": { + "data": { + "name": "Ime", + "url": "URL spletnega vmesnika" + } + }, + "user": { + "data": { + "name": "Ime", + "url": "URL spletnega vmesnika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/es.json b/homeassistant/components/synology_dsm/translations/es.json index 390f67d4667..a498333e049 100644 --- a/homeassistant/components/synology_dsm/translations/es.json +++ b/homeassistant/components/synology_dsm/translations/es.json @@ -21,7 +21,7 @@ "link": { "data": { "password": "Contrase\u00f1a", - "port": "Puerto (opcional)", + "port": "Puerto", "ssl": "Usar SSL/TLS para conectar con tu NAS", "username": "Usuario" }, @@ -32,7 +32,7 @@ "data": { "host": "Host", "password": "Contrase\u00f1a", - "port": "Puerto (opcional)", + "port": "Puerto", "ssl": "Usar SSL/TLS para conectar con tu NAS", "username": "Usuario" }, diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json index 43c3c450f93..f8d7add4dc2 100644 --- a/homeassistant/components/synology_dsm/translations/no.json +++ b/homeassistant/components/synology_dsm/translations/no.json @@ -21,7 +21,7 @@ "link": { "data": { "password": "Passord", - "port": "Port", + "port": "", "ssl": "Bruk SSL/TLS til \u00e5 koble til NAS-en", "username": "Brukernavn" }, @@ -32,7 +32,7 @@ "data": { "host": "Vert", "password": "Passord", - "port": "Port", + "port": "", "ssl": "Bruk SSL/TLS til \u00e5 koble til NAS-en", "username": "Brukernavn" }, diff --git a/homeassistant/components/system_health/translations/es.json b/homeassistant/components/system_health/translations/es.json index 11ee35782b1..ada0964a358 100644 --- a/homeassistant/components/system_health/translations/es.json +++ b/homeassistant/components/system_health/translations/es.json @@ -1,3 +1,3 @@ { - "title": "Estado del Sistema" + "title": "Estado del sistema" } \ No newline at end of file diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index bf49de5a731..6f658962fe0 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -1,6 +1,8 @@ """Support for system log.""" +import asyncio from collections import OrderedDict, deque import logging +import queue import re import traceback @@ -8,7 +10,8 @@ import voluptuous as vol from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components.http import HomeAssistantView -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_MAX_ENTRIES = "max_entries" @@ -155,6 +158,19 @@ class DedupStore(OrderedDict): return [value.to_dict() for value in reversed(self.values())] +class LogErrorQueueHandler(logging.handlers.QueueHandler): + """Process the log in another thread.""" + + def emit(self, record): + """Emit a log record.""" + try: + self.enqueue(record) + except asyncio.CancelledError: # pylint: disable=try-except-raise + raise + except Exception: # pylint: disable=broad-except + self.handleError(record) + + class LogErrorHandler(logging.Handler): """Log handler for error messages.""" @@ -172,17 +188,14 @@ class LogErrorHandler(logging.Handler): default upper limit is set to 50 (older entries are discarded) but can be changed if needed. """ - if record.levelno >= logging.WARN: - stack = [] - if not record.exc_info: - stack = [(f[0], f[1]) for f in traceback.extract_stack()] + stack = [] + if not record.exc_info: + stack = [(f[0], f[1]) for f in traceback.extract_stack()] - entry = LogEntry( - record, stack, _figure_out_source(record, stack, self.hass) - ) - self.records.add_entry(entry) - if self.fire_event: - self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict()) + entry = LogEntry(record, stack, _figure_out_source(record, stack, self.hass)) + self.records.add_entry(entry) + if self.fire_event: + self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict()) async def async_setup(hass, config): @@ -191,8 +204,26 @@ async def async_setup(hass, config): if conf is None: conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + simple_queue = queue.SimpleQueue() + queue_handler = LogErrorQueueHandler(simple_queue) + queue_handler.setLevel(logging.WARN) + logging.root.addHandler(queue_handler) + handler = LogErrorHandler(hass, conf[CONF_MAX_ENTRIES], conf[CONF_FIRE_EVENT]) - logging.getLogger().addHandler(handler) + + listener = logging.handlers.QueueListener( + simple_queue, handler, respect_handler_level=True + ) + + listener.start() + + @callback + def _async_stop_queue_handler(_) -> None: + """Cleanup handler.""" + logging.root.removeHandler(queue_handler) + listener.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_stop_queue_handler) hass.http.register_view(AllErrorsView(handler)) diff --git a/homeassistant/components/tado/services.yaml b/homeassistant/components/tado/services.yaml index 5a0fd78d26e..864511982a3 100644 --- a/homeassistant/components/tado/services.yaml +++ b/homeassistant/components/tado/services.yaml @@ -1,5 +1,5 @@ set_climate_timer: - description: Turn on cliate entities for a set time. + description: Turn on climate entities for a set time. fields: entity_id: description: Entity ID for the tado component to turn on for a set time. diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index b7ad219eff7..4209388ae8a 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -17,6 +17,7 @@ from homeassistant.components.alarm_control_panel.const import ( from homeassistant.const import ( ATTR_CODE, CONF_NAME, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -62,6 +63,7 @@ ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -86,6 +88,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= arm_home_action = device_config.get(CONF_ARM_HOME_ACTION) arm_night_action = device_config.get(CONF_ARM_NIGHT_ACTION) code_arm_required = device_config[CONF_CODE_ARM_REQUIRED] + unique_id = device_config.get(CONF_UNIQUE_ID) template_entity_ids = set() @@ -111,6 +114,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= arm_night_action, code_arm_required, template_entity_ids, + unique_id, ) ) @@ -132,6 +136,7 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity): arm_night_action, code_arm_required, template_entity_ids, + unique_id, ): """Initialize the panel.""" self.hass = hass @@ -156,6 +161,7 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity): self._state = None self._entities = template_entity_ids + self._unique_id = unique_id if self._template is not None: self._template.hass = self.hass @@ -165,6 +171,11 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity): """Return the display name of this alarm control panel.""" return self._name + @property + def unique_id(self): + """Return the unique id of this alarm control panel.""" + return self._unique_id + @property def should_poll(self): """Return the polling state.""" diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 101651fabd5..22eb8b9d242 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_SENSORS, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -50,6 +51,7 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_DELAY_ON): vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_DELAY_OFF): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -73,6 +75,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class = device_config.get(CONF_DEVICE_CLASS) delay_on = device_config.get(CONF_DELAY_ON) delay_off = device_config.get(CONF_DELAY_OFF) + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: value_template, @@ -104,6 +107,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= delay_on, delay_off, attribute_templates, + unique_id, ) ) @@ -127,6 +131,7 @@ class BinarySensorTemplate(BinarySensorEntity): delay_on, delay_off, attribute_templates, + unique_id, ): """Initialize the Template binary sensor.""" self.hass = hass @@ -146,6 +151,7 @@ class BinarySensorTemplate(BinarySensorEntity): self._available = True self._attribute_templates = attribute_templates self._attributes = {} + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -175,6 +181,11 @@ class BinarySensorTemplate(BinarySensorEntity): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return the unique id of this binary sensor.""" + return self._unique_id + @property def icon(self): """Return the icon to use in the frontend, if any.""" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index e2f67acf2bd..08dd18ae3a4 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -26,6 +26,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_ICON_TEMPLATE, CONF_OPTIMISTIC, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -90,6 +91,7 @@ COVER_SCHEMA = vol.All( vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), @@ -121,6 +123,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= tilt_action = device_config.get(TILT_ACTION) optimistic = device_config.get(CONF_OPTIMISTIC) tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -156,6 +159,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= optimistic, tilt_optimistic, entity_ids, + unique_id, ) ) @@ -185,6 +189,7 @@ class CoverTemplate(CoverEntity): optimistic, tilt_optimistic, entity_ids, + unique_id, ): """Initialize the Template cover.""" self.hass = hass @@ -222,6 +227,7 @@ class CoverTemplate(CoverEntity): self._tilt_value = None self._entities = entity_ids self._available = True + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -251,6 +257,11 @@ class CoverTemplate(CoverEntity): """Return the name of the cover.""" return self._name + @property + def unique_id(self): + """Return the unique id of this cover.""" + return self._unique_id + @property def is_closed(self): """Return if the cover is closed.""" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 037cc6c40e1..a6a0f6b8135 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -21,11 +21,13 @@ from homeassistant.components.fan import ( from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import callback @@ -71,6 +73,7 @@ FAN_SCHEMA = vol.Schema( CONF_SPEED_LIST, default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] ): cv.ensure_list, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -99,6 +102,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= set_direction_action = device_config.get(CONF_SET_DIRECTION_ACTION) speed_list = device_config[CONF_SPEED_LIST] + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -128,6 +132,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= set_direction_action, speed_list, entity_ids, + unique_id, ) ) @@ -154,6 +159,7 @@ class TemplateFan(FanEntity): set_direction_action, speed_list, entity_ids, + unique_id, ): """Initialize the fan.""" self.hass = hass @@ -198,6 +204,8 @@ class TemplateFan(FanEntity): self._supported_features |= SUPPORT_DIRECTION self._entities = entity_ids + self._unique_id = unique_id + # List of valid speeds self._speed_list = speed_list @@ -206,6 +214,11 @@ class TemplateFan(FanEntity): """Return the display name of this fan.""" return self._name + @property + def unique_id(self): + """Return the unique id of this fan.""" + return self._unique_id + @property def supported_features(self) -> int: """Flag supported features.""" @@ -344,7 +357,7 @@ class TemplateFan(FanEntity): # Validate state if state in _VALID_STATES: self._state = state - elif state == STATE_UNKNOWN: + elif state in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._state = None else: _LOGGER.error( @@ -366,7 +379,7 @@ class TemplateFan(FanEntity): # Validate speed if speed in self._speed_list: self._speed = speed - elif speed == STATE_UNKNOWN: + elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._speed = None else: _LOGGER.error( @@ -388,7 +401,7 @@ class TemplateFan(FanEntity): self._oscillating = True elif oscillating == "False" or oscillating is False: self._oscillating = False - elif oscillating == STATE_UNKNOWN: + elif oscillating in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._oscillating = None else: _LOGGER.error( @@ -409,7 +422,7 @@ class TemplateFan(FanEntity): # Validate speed if direction in _VALID_DIRECTIONS: self._direction = direction - elif direction == STATE_UNKNOWN: + elif direction in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._direction = None else: _LOGGER.error( diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 6832ca04017..b85aa6f3a95 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_ICON_TEMPLATE, CONF_LIGHTS, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -70,6 +71,7 @@ LIGHT_SCHEMA = vol.Schema( vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_WHITE_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_WHITE_VALUE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -89,6 +91,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= icon_template = device_config.get(CONF_ICON_TEMPLATE) entity_picture_template = device_config.get(CONF_ENTITY_PICTURE_TEMPLATE) availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) + unique_id = device_config.get(CONF_UNIQUE_ID) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] @@ -141,6 +144,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= color_template, white_value_action, white_value_template, + unique_id, ) ) @@ -170,6 +174,7 @@ class LightTemplate(LightEntity): color_template, white_value_action, white_value_template, + unique_id, ): """Initialize the light.""" self.hass = hass @@ -209,6 +214,7 @@ class LightTemplate(LightEntity): self._white_value = None self._entities = entity_ids self._available = True + self._unique_id = unique_id @property def brightness(self): @@ -235,6 +241,11 @@ class LightTemplate(LightEntity): """Return the display name of this light.""" return self._name + @property + def unique_id(self): + """Return the unique id of this light.""" + return self._unique_id + @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 0d8cdd7d290..07aeda70be1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -7,6 +7,7 @@ from homeassistant.components.lock import PLATFORM_SCHEMA, LockEntity from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -38,6 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -67,6 +69,7 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=N config.get(CONF_LOCK), config.get(CONF_UNLOCK), config.get(CONF_OPTIMISTIC), + config.get(CONF_UNIQUE_ID), ) ] ) @@ -85,6 +88,7 @@ class TemplateLock(LockEntity): command_lock, command_unlock, optimistic, + unique_id, ): """Initialize the lock.""" self._state = None @@ -97,6 +101,7 @@ class TemplateLock(LockEntity): self._command_unlock = Script(hass, command_unlock) self._optimistic = optimistic self._available = True + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -135,6 +140,11 @@ class TemplateLock(LockEntity): """Return the name of the lock.""" return self._name + @property + def unique_id(self): + """Return the unique id of this lock.""" + return self._unique_id + @property def is_locked(self): """Return true if lock is locked.""" diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index 4ad03db22bb..dd2f8d1e0c6 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -3,5 +3,6 @@ "name": "Template", "documentation": "https://www.home-assistant.io/integrations/template", "codeowners": ["@PhracturedBlue", "@tetienne"], - "quality_scale": "internal" + "quality_scale": "internal", + "after_dependencies": ["group"] } diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index d4977d626ca..53736050ed3 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME_TEMPLATE, CONF_ICON_TEMPLATE, CONF_SENSORS, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -49,6 +50,7 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -71,6 +73,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT) device_class = device_config.get(CONF_DEVICE_CLASS) attribute_templates = device_config[CONF_ATTRIBUTE_TEMPLATES] + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -103,6 +106,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entity_ids, device_class, attribute_templates, + unique_id, ) ) @@ -128,6 +132,7 @@ class SensorTemplate(Entity): entity_ids, device_class, attribute_templates, + unique_id, ): """Initialize the sensor.""" self.hass = hass @@ -149,6 +154,7 @@ class SensorTemplate(Entity): self._available = True self._attribute_templates = attribute_templates self._attributes = {} + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -178,6 +184,11 @@ class SensorTemplate(Entity): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return the unique id of this sensor.""" + return self._unique_id + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 124d12d194f..f9b07fa1dec 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_SWITCHES, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -47,6 +48,7 @@ SWITCH_SCHEMA = vol.Schema( vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -67,6 +69,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) on_action = device_config[ON_ACTION] off_action = device_config[OFF_ACTION] + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -92,6 +95,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= on_action, off_action, entity_ids, + unique_id, ) ) @@ -113,6 +117,7 @@ class SwitchTemplate(SwitchEntity, RestoreEntity): on_action, off_action, entity_ids, + unique_id, ): """Initialize the Template switch.""" self.hass = hass @@ -131,6 +136,7 @@ class SwitchTemplate(SwitchEntity, RestoreEntity): self._entity_picture = None self._entities = entity_ids self._available = True + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -172,6 +178,11 @@ class SwitchTemplate(SwitchEntity, RestoreEntity): """Return the name of the switch.""" return self._name + @property + def unique_id(self): + """Return the unique id of this switch.""" + return self._unique_id + @property def is_on(self): """Return true if device is on.""" diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1209e617a7e..a61a1690e5a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -33,6 +33,7 @@ from homeassistant.components.vacuum import ( from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -84,6 +85,7 @@ VACUUM_SCHEMA = vol.Schema( vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -114,6 +116,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= set_fan_speed_action = device_config.get(SERVICE_SET_FAN_SPEED) fan_speed_list = device_config[CONF_FAN_SPEED_LIST] + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -146,6 +149,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= fan_speed_list, entity_ids, attribute_templates, + unique_id, ) ) @@ -174,6 +178,7 @@ class TemplateVacuum(StateVacuumEntity): fan_speed_list, entity_ids, attribute_templates, + unique_id, ): """Initialize the vacuum.""" self.hass = hass @@ -233,6 +238,8 @@ class TemplateVacuum(StateVacuumEntity): self._supported_features |= SUPPORT_BATTERY self._entities = entity_ids + self._unique_id = unique_id + # List of valid fan speeds self._fan_speed_list = fan_speed_list @@ -241,6 +248,11 @@ class TemplateVacuum(StateVacuumEntity): """Return the display name of this vacuum.""" return self._name + @property + def unique_id(self): + """Return the unique id of this vacuum.""" + return self._unique_id + @property def supported_features(self) -> int: """Flag supported features.""" diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index f4eb5342c46..420c3403a11 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -3,9 +3,11 @@ import io import logging import os import sys +import time from PIL import Image, ImageDraw, UnidentifiedImageError import numpy as np +import tensorflow as tf # pylint: disable=import-error import voluptuous as vol from homeassistant.components.image_processing import ( @@ -16,16 +18,21 @@ from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingEntity, ) +from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv from homeassistant.util.pil import draw_box +os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" + +DOMAIN = "tensorflow" _LOGGER = logging.getLogger(__name__) ATTR_MATCHES = "matches" ATTR_SUMMARY = "summary" ATTR_TOTAL_MATCHES = "total_matches" +ATTR_PROCESS_TIME = "process_time" CONF_AREA = "area" CONF_BOTTOM = "bottom" @@ -34,6 +41,7 @@ CONF_CATEGORY = "category" CONF_FILE_OUT = "file_out" CONF_GRAPH = "graph" CONF_LABELS = "labels" +CONF_LABEL_OFFSET = "label_offset" CONF_LEFT = "left" CONF_MODEL = "model" CONF_MODEL_DIR = "model_dir" @@ -58,12 +66,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_FILE_OUT, default=[]): vol.All(cv.ensure_list, [cv.template]), vol.Required(CONF_MODEL): vol.Schema( { - vol.Required(CONF_GRAPH): cv.isfile, + vol.Required(CONF_GRAPH): cv.isdir, vol.Optional(CONF_AREA): AREA_SCHEMA, vol.Optional(CONF_CATEGORIES, default=[]): vol.All( cv.ensure_list, [vol.Any(cv.string, CATEGORY_SCHEMA)] ), vol.Optional(CONF_LABELS): cv.isfile, + vol.Optional(CONF_LABEL_OFFSET, default=1): int, vol.Optional(CONF_MODEL_DIR): cv.isdir, } ), @@ -71,17 +80,40 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +def get_model_detection_function(model): + """Get a tf.function for detection.""" + + @tf.function + def detect_fn(image): + """Detect objects in image.""" + + image, shapes = model.preprocess(image) + prediction_dict = model.predict(image, shapes) + detections = model.postprocess(prediction_dict, shapes) + + return detections + + return detect_fn + + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the TensorFlow image processing platform.""" - model_config = config.get(CONF_MODEL) + model_config = config[CONF_MODEL] model_dir = model_config.get(CONF_MODEL_DIR) or hass.config.path("tensorflow") labels = model_config.get(CONF_LABELS) or hass.config.path( "tensorflow", "object_detection", "data", "mscoco_label_map.pbtxt" ) + checkpoint = os.path.join(model_config[CONF_GRAPH], "checkpoint") + pipeline_config = os.path.join(model_config[CONF_GRAPH], "pipeline.config") # Make sure locations exist - if not os.path.isdir(model_dir) or not os.path.exists(labels): - _LOGGER.error("Unable to locate tensorflow models or label map") + if ( + not os.path.isdir(model_dir) + or not os.path.isdir(checkpoint) + or not os.path.exists(pipeline_config) + or not os.path.exists(labels) + ): + _LOGGER.error("Unable to locate tensorflow model or label map") return # append custom model path to sys.path @@ -89,18 +121,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: # Verify that the TensorFlow Object Detection API is pre-installed - os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" # These imports shouldn't be moved to the top, because they depend on code from the model_dir. # (The model_dir is created during the manual setup process. See integration docs.) - import tensorflow as tf # pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel - from object_detection.utils import label_map_util + from object_detection.utils import config_util, label_map_util + from object_detection.builders import model_builder except ImportError: _LOGGER.error( "No TensorFlow Object Detection library found! Install or compile " "for your system following instructions here: " - "https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/installation.md" + "https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/tf2.md#installation" ) return @@ -113,22 +144,45 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "PIL at reduced resolution" ) - # Set up Tensorflow graph, session, and label map to pass to processor - # pylint: disable=no-member - detection_graph = tf.Graph() - with detection_graph.as_default(): - od_graph_def = tf.GraphDef() - with tf.gfile.GFile(model_config.get(CONF_GRAPH), "rb") as fid: - serialized_graph = fid.read() - od_graph_def.ParseFromString(serialized_graph) - tf.import_graph_def(od_graph_def, name="") + hass.data[DOMAIN] = {CONF_MODEL: None} - session = tf.Session(graph=detection_graph) - label_map = label_map_util.load_labelmap(labels) - categories = label_map_util.convert_label_map_to_categories( - label_map, max_num_classes=90, use_display_name=True + def tensorflow_hass_start(_event): + """Set up TensorFlow model on hass start.""" + start = time.perf_counter() + + # Load pipeline config and build a detection model + pipeline_configs = config_util.get_configs_from_pipeline_file(pipeline_config) + detection_model = model_builder.build( + model_config=pipeline_configs["model"], is_training=False + ) + + # Restore checkpoint + ckpt = tf.compat.v2.train.Checkpoint(model=detection_model) + ckpt.restore(os.path.join(checkpoint, "ckpt-0")).expect_partial() + + _LOGGER.debug( + "Model checkpoint restore took %d seconds", time.perf_counter() - start + ) + + model = get_model_detection_function(detection_model) + + # Preload model cache with empty image tensor + inp = np.zeros([2160, 3840, 3], dtype=np.uint8) + # The input needs to be a tensor, convert it using `tf.convert_to_tensor`. + input_tensor = tf.convert_to_tensor(inp, dtype=tf.float32) + # The model expects a batch of images, so add an axis with `tf.newaxis`. + input_tensor = input_tensor[tf.newaxis, ...] + # Run inference + model(input_tensor) + + _LOGGER.debug("Model load took %d seconds", time.perf_counter() - start) + hass.data[DOMAIN][CONF_MODEL] = model + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, tensorflow_hass_start) + + category_index = label_map_util.create_category_index_from_labelmap( + labels, use_display_name=True ) - category_index = label_map_util.create_category_index(categories) entities = [] @@ -138,8 +192,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hass, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), - session, - detection_graph, category_index, config, ) @@ -152,14 +204,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): """Representation of an TensorFlow image processor.""" def __init__( - self, - hass, - camera_entity, - name, - session, - detection_graph, - category_index, - config, + self, hass, camera_entity, name, category_index, config, ): """Initialize the TensorFlow entity.""" model_config = config.get(CONF_MODEL) @@ -169,13 +214,12 @@ class TensorFlowImageProcessor(ImageProcessingEntity): self._name = name else: self._name = "TensorFlow {}".format(split_entity_id(camera_entity)[1]) - self._session = session - self._graph = detection_graph self._category_index = category_index self._min_confidence = config.get(CONF_CONFIDENCE) self._file_out = config.get(CONF_FILE_OUT) # handle categories and specific detection areas + self._label_id_offset = model_config.get(CONF_LABEL_OFFSET) categories = model_config.get(CONF_CATEGORIES) self._include_categories = [] self._category_areas = {} @@ -212,6 +256,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): self._matches = {} self._total_matches = 0 self._last_image = None + self._process_time = 0 @property def camera_entity(self): @@ -237,6 +282,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): category: len(values) for category, values in self._matches.items() }, ATTR_TOTAL_MATCHES: self._total_matches, + ATTR_PROCESS_TIME: self._process_time, } def _save_image(self, image, matches, paths): @@ -281,10 +327,16 @@ class TensorFlowImageProcessor(ImageProcessingEntity): def process_image(self, image): """Process the image.""" + model = self.hass.data[DOMAIN][CONF_MODEL] + if not model: + _LOGGER.debug("Model not yet ready.") + return + start = time.perf_counter() try: import cv2 # pylint: disable=import-error, import-outside-toplevel + # pylint: disable=no-member img = cv2.imdecode(np.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED) inp = img[:, :, [2, 1, 0]] # BGR->RGB inp_expanded = inp.reshape(1, inp.shape[0], inp.shape[1], 3) @@ -303,15 +355,15 @@ class TensorFlowImageProcessor(ImageProcessingEntity): ) inp_expanded = np.expand_dims(inp, axis=0) - image_tensor = self._graph.get_tensor_by_name("image_tensor:0") - boxes = self._graph.get_tensor_by_name("detection_boxes:0") - scores = self._graph.get_tensor_by_name("detection_scores:0") - classes = self._graph.get_tensor_by_name("detection_classes:0") - boxes, scores, classes = self._session.run( - [boxes, scores, classes], feed_dict={image_tensor: inp_expanded} - ) - boxes, scores, classes = map(np.squeeze, [boxes, scores, classes]) - classes = classes.astype(int) + # The input needs to be a tensor, convert it using `tf.convert_to_tensor`. + input_tensor = tf.convert_to_tensor(inp_expanded, dtype=tf.float32) + + detections = model(input_tensor) + boxes = detections["detection_boxes"][0].numpy() + scores = detections["detection_scores"][0].numpy() + classes = ( + detections["detection_classes"][0].numpy() + self._label_id_offset + ).astype(int) matches = {} total_matches = 0 @@ -367,3 +419,4 @@ class TensorFlowImageProcessor(ImageProcessingEntity): self._matches = matches self._total_matches = total_matches + self._process_time = time.perf_counter() - start diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index b74633d36d4..fc87b5cdbff 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -3,9 +3,12 @@ "name": "TensorFlow", "documentation": "https://www.home-assistant.io/integrations/tensorflow", "requirements": [ - "tensorflow==1.13.2", - "numpy==1.19.0", - "protobuf==3.6.1", + "tensorflow==2.2.0", + "tf-slim==1.1.0", + "tf-models-official==2.2.1", + "pycocotools==2.0.1", + "numpy==1.19.1", + "protobuf==3.12.2", "pillow==7.1.2" ], "codeowners": [] diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index 82c98518f48..67ebe90669d 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -15,12 +15,10 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME, - DEVICE_CLASS_BATTERY, ) from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify from .config_flow import ( @@ -223,14 +221,8 @@ class TeslaDevice(Entity): @property def icon(self): """Return the icon of the sensor.""" - if ( - self.device_class == DEVICE_CLASS_BATTERY - and self.tesla_device.has_battery() - ): - return icon_for_battery_level( - battery_level=self.tesla_device.battery_level(), - charging=self.tesla_device.battery_charging(), - ) + if self.device_class: + return None return self._icon diff --git a/homeassistant/components/tesla/binary_sensor.py b/homeassistant/components/tesla/binary_sensor.py index c1f6fe18b99..c6b63d92bd2 100644 --- a/homeassistant/components/tesla/binary_sensor.py +++ b/homeassistant/components/tesla/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Tesla binary sensor.""" import logging -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity from . import DOMAIN as TESLA_DOMAIN, TeslaDevice @@ -15,7 +15,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): TeslaBinarySensor( device, hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"], - "connectivity", config_entry, ) for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ @@ -29,22 +28,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TeslaBinarySensor(TeslaDevice, BinarySensorEntity): """Implement an Tesla binary sensor for parking and charger.""" - def __init__(self, tesla_device, controller, sensor_type, config_entry): + def __init__(self, tesla_device, controller, config_entry): """Initialise of a Tesla binary sensor.""" super().__init__(tesla_device, controller, config_entry) - self._state = False - self._sensor_type = sensor_type + self._state = None + self._sensor_type = None + if tesla_device.sensor_type in DEVICE_CLASSES: + self._sensor_type = tesla_device.sensor_type @property def device_class(self): """Return the class of this binary sensor.""" return self._sensor_type - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - @property def is_on(self): """Return the state of the binary sensor.""" diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index 1db32bbd61f..fab844eb8eb 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -3,6 +3,6 @@ "name": "Tesla", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tesla", - "requirements": ["teslajsonpy==0.9.3"], + "requirements": ["teslajsonpy==0.10.1"], "codeowners": ["@zabuldon", "@alandtse"] } diff --git a/homeassistant/components/tesla/translations/es.json b/homeassistant/components/tesla/translations/es.json index e0f5f81e5ae..8bb659d377f 100644 --- a/homeassistant/components/tesla/translations/es.json +++ b/homeassistant/components/tesla/translations/es.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Contrase\u00f1a", - "username": "Direcci\u00f3n de correo electr\u00f3nico" + "username": "Correo electr\u00f3nico" }, "description": "Por favor, introduzca su informaci\u00f3n.", "title": "Tesla - Configuraci\u00f3n" diff --git a/homeassistant/components/tile/translations/no.json b/homeassistant/components/tile/translations/no.json index 1185ebc2bdd..ccd8c0fc176 100644 --- a/homeassistant/components/tile/translations/no.json +++ b/homeassistant/components/tile/translations/no.json @@ -8,6 +8,10 @@ }, "step": { "user": { + "data": { + "password": "Passord", + "username": "E-post" + }, "title": "Konfigurer Tile" } } diff --git a/homeassistant/components/tile/translations/pl.json b/homeassistant/components/tile/translations/pl.json index b8b737c37a3..09fbdc93241 100644 --- a/homeassistant/components/tile/translations/pl.json +++ b/homeassistant/components/tile/translations/pl.json @@ -4,7 +4,7 @@ "user": { "data": { "password": "Has\u0142o", - "username": "Nazwa u\u017cytkownika" + "username": "Adres e-mail" } } } diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 6081f1dfca6..b9012385296 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -42,15 +42,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("Timezone is not set in Home Assistant configuration") return False - devices = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - device = TimeDateSensor(hass, variable) - async_track_point_in_utc_time( - hass, device.point_in_time_listener, device.get_next_interval() - ) - devices.append(device) - - async_add_entities(devices, True) + async_add_entities( + [TimeDateSensor(hass, variable) for variable in config[CONF_DISPLAY_OPTIONS]] + ) class TimeDateSensor(Entity): @@ -62,6 +56,7 @@ class TimeDateSensor(Entity): self.type = option_type self._state = None self.hass = hass + self.unsub = None self._update_internal_state(dt_util.utcnow()) @@ -84,6 +79,18 @@ class TimeDateSensor(Entity): return "mdi:calendar" return "mdi:clock" + async def async_added_to_hass(self) -> None: + """Set up next update.""" + self.unsub = async_track_point_in_utc_time( + self.hass, self.point_in_time_listener, self.get_next_interval() + ) + + async def async_will_remove_from_hass(self) -> None: + """Cancel next update.""" + if self.unsub: + self.unsub() + self.unsub = None + def get_next_interval(self, now=None): """Compute next time an update should occur.""" if now is None: @@ -137,6 +144,6 @@ class TimeDateSensor(Entity): """Get the latest data and update state.""" self._update_internal_state(time_date) self.async_write_ha_state() - async_track_point_in_utc_time( + self.unsub = async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.get_next_interval() ) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 5015d50fa63..c814134f767 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -31,6 +31,8 @@ DEFAULT_MIN_TEMP = 6.0 CURRENCY_EUR = "EUR" VOLUME_CM3 = "CM3" VOLUME_M3 = "M3" +VOLUME_LHOUR = "L/H" +VOLUME_LMIN = "L/MIN" ATTR_DEFAULT_ENABLED = "default_enabled" ATTR_INVERTED = "inverted" @@ -338,6 +340,60 @@ SENSOR_ENTITIES = { ATTR_ICON: "mdi:solar-power", ATTR_DEFAULT_ENABLED: True, }, + "water_average": { + ATTR_NAME: "Average Water Usage", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "average", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_LMIN, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water", + ATTR_DEFAULT_ENABLED: False, + }, + "water_average_daily": { + ATTR_NAME: "Average Daily Water Usage", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "day_average", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water", + ATTR_DEFAULT_ENABLED: False, + }, + "water_daily_usage": { + ATTR_NAME: "Water Usage Today", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "day_usage", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water", + ATTR_DEFAULT_ENABLED: False, + }, + "water_meter_reading": { + ATTR_NAME: "Water Meter", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "meter", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water", + ATTR_DEFAULT_ENABLED: False, + }, + "water_value": { + ATTR_NAME: "Current Water Usage", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "current", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_LMIN, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water-pump", + ATTR_DEFAULT_ENABLED: False, + }, + "water_daily_cost": { + ATTR_NAME: "Water Cost Today", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "day_cost", + ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water-pump", + ATTR_DEFAULT_ENABLED: False, + }, } SWITCH_ENTITIES = { diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 640fa9bb04e..fa4cf52a630 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -21,8 +21,8 @@ from .const import CONF_CLOUDHOOK_URL, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) -class ToonDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching WLED data from single endpoint.""" +class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): + """Class to manage fetching Toon data from single endpoint.""" def __init__( self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index 2ced62ffc6c..87398fab302 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -3,7 +3,7 @@ "name": "Toon", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/toon", - "requirements": ["toonapi==0.1.0"], + "requirements": ["toonapi==0.2.0"], "dependencies": ["http"], "after_dependencies": ["cloud"], "codeowners": ["@frenck"] diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 7634246d1c9..441b718c40a 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -110,6 +110,20 @@ class ToonGasMeterDeviceEntity(ToonEntity): } +class ToonWaterMeterDeviceEntity(ToonEntity): + """Defines a Water Meter device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this entity.""" + agreement_id = self.coordinator.data.agreement.agreement_id + return { + "name": "Water Meter", + "identifiers": {(DOMAIN, agreement_id, "water")}, + "via_device": (DOMAIN, agreement_id, "electricity"), + } + + class ToonSolarDeviceEntity(ToonEntity): """Defines a Solar Device device entity.""" diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 2a0604c6c74..e686ff28211 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -24,6 +24,7 @@ from .models import ( ToonEntity, ToonGasMeterDeviceEntity, ToonSolarDeviceEntity, + ToonWaterMeterDeviceEntity, ) _LOGGER = logging.getLogger(__name__) @@ -68,6 +69,20 @@ async def async_setup_entry( ] ) + sensors.extend( + [ + ToonWaterMeterDeviceSensor(coordinator, key=key) + for key in ( + "water_average_daily", + "water_average", + "water_daily_cost", + "water_daily_usage", + "water_meter_reading", + "water_value", + ) + ] + ) + if coordinator.data.agreement.is_toon_solar: sensors.extend( [ @@ -146,6 +161,10 @@ class ToonGasMeterDeviceSensor(ToonSensor, ToonGasMeterDeviceEntity): """Defines a Gas Meter sensor.""" +class ToonWaterMeterDeviceSensor(ToonSensor, ToonWaterMeterDeviceEntity): + """Defines a Water Meter sensor.""" + + class ToonSolarDeviceSensor(ToonSensor, ToonSolarDeviceEntity): """Defines a Solar sensor.""" diff --git a/homeassistant/components/toon/translations/no.json b/homeassistant/components/toon/translations/no.json index 1aa78d734c1..b67f7386a22 100644 --- a/homeassistant/components/toon/translations/no.json +++ b/homeassistant/components/toon/translations/no.json @@ -3,8 +3,10 @@ "abort": { "already_configured": "Den valgte avtalen er allerede konfigurert.", "authorize_url_fail": "Ukjent feil ved generering av autoriseringsadresse.", + "authorize_url_timeout": "Tidsavbrudd som genererer autorer URL-adresse.", "client_id": "Klient ID fra konfigurasjonen er ugyldig.", "client_secret": "Klient hemmeligheten fra konfigurasjonen er ugyldig.", + "missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.", "no_agreements": "Denne kontoen har ingen Toon skjermer.", "no_app": "Du m\u00e5 konfigurere Toon f\u00f8r du kan autentisere den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/toon/).", "unknown_auth_fail": "Det oppstod en uventet feil under godkjenning." diff --git a/homeassistant/components/toon/translations/pl.json b/homeassistant/components/toon/translations/pl.json index 40bf4e2015a..43dc3d635c4 100644 --- a/homeassistant/components/toon/translations/pl.json +++ b/homeassistant/components/toon/translations/pl.json @@ -1,8 +1,10 @@ { "config": { "abort": { + "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji.", "client_id": "Identyfikator klienta z konfiguracji jest nieprawid\u0142owy.", "client_secret": "Tajny klucz klienta z konfiguracji jest nieprawid\u0142owy.", + "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", "no_agreements": "To konto nie posiada wy\u015bwietlaczy Toon.", "no_app": "Musisz skonfigurowa\u0107 Toon, aby m\u00f3c si\u0119 z nim uwierzytelni\u0107. Zapoznaj si\u0119 z [instrukcj\u0105](https://www.home-assistant.io/components/toon/).", "unknown_auth_fail": "Nieoczekiwany b\u0142\u0105d podczas uwierzytelniania." diff --git a/homeassistant/components/transmission/translations/no.json b/homeassistant/components/transmission/translations/no.json index f88e7c55ea4..89150467222 100644 --- a/homeassistant/components/transmission/translations/no.json +++ b/homeassistant/components/transmission/translations/no.json @@ -12,9 +12,11 @@ "user": { "data": { "host": "Vert", + "limit": "Grense", "name": "Navn", + "order": "Rekkef\u00f8lge", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn" }, "title": "Oppsett av Transmission-klient" @@ -25,6 +27,8 @@ "step": { "init": { "data": { + "limit": "Grense", + "order": "Rekkef\u00f8lge", "scan_interval": "Oppdater frekvens" }, "title": "Konfigurer alternativer for Transmission" diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index dabeabd2757..a43c2bb0cce 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.19.0"], + "requirements": ["numpy==1.19.1"], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json index 7b336c3f9c6..5681f95d984 100644 --- a/homeassistant/components/tuya/translations/no.json +++ b/homeassistant/components/tuya/translations/no.json @@ -1,14 +1,24 @@ { "config": { + "abort": { + "auth_failed": "Ugyldig godkjenning", + "conn_error": "Tilkobling mislyktes.", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "auth_failed": "Ugyldig godkjenning" + }, "flow_title": "Tuya konfigurasjon", "step": { "user": { "data": { "country_code": "Din landskode for kontoen din (f.eks. 1 for USA eller 86 for Kina)", - "platform": "Appen der kontoen din registreres" + "password": "Passord", + "platform": "Appen der kontoen din registreres", + "username": "Brukernavn" }, "description": "Skriv inn din Tuya-legitimasjon.", - "title": "Tuya" + "title": "" } } } diff --git a/homeassistant/components/unifi/translations/es.json b/homeassistant/components/unifi/translations/es.json index 1867bd89ffd..da67cd1bcaf 100644 --- a/homeassistant/components/unifi/translations/es.json +++ b/homeassistant/components/unifi/translations/es.json @@ -44,12 +44,6 @@ "description": "Configurar dispositivo de seguimiento", "title": "Opciones UniFi 1/3" }, - "init": { - "data": { - "one": "vac\u00edo", - "other": "vac\u00edo" - } - }, "simple_options": { "data": { "block_client": "Acceso controlado a la red de los clientes", diff --git a/homeassistant/components/unifi/translations/no.json b/homeassistant/components/unifi/translations/no.json index 6e149217f11..a861790ba8d 100644 --- a/homeassistant/components/unifi/translations/no.json +++ b/homeassistant/components/unifi/translations/no.json @@ -13,7 +13,7 @@ "data": { "host": "Vert", "password": "Passord", - "port": "Port", + "port": "", "site": "Nettsted-ID", "username": "Brukernavn", "verify_ssl": "Kontroller bruker riktig sertifikat" @@ -52,6 +52,7 @@ }, "simple_options": { "data": { + "block_client": "Nettverkskontrollerte klienter", "track_clients": "Spor nettverksklienter", "track_devices": "Spor nettverksenheter (Ubiquiti enheter)" }, diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index 0b53850733f..f3c9483e4a8 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -64,7 +64,7 @@ async def async_setup(hass, config): include_components = conf.get(CONF_COMPONENT_REPORTING) - async def check_new_version(): + async def check_new_version() -> Updater: """Check if a new version is available and report if one is.""" newest, release_notes = await get_newest_version( hass, huuid, include_components @@ -76,9 +76,10 @@ async def async_setup(hass, config): if "dev" in current_version: return Updater(False, "", "") - # Load data from supervisor on Hass.io + # Load data from Supervisor if hass.components.hassio.is_hassio(): - newest = hass.components.hassio.get_homeassistant_version() + core_info = hass.components.hassio.get_core_info() + newest = core_info["version_latest"] # Validate version update_available = False @@ -98,7 +99,7 @@ async def async_setup(hass, config): return Updater(update_available, newest, release_notes) - coordinator = hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( + coordinator = hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator[Updater]( hass, _LOGGER, name="Home Assistant update", @@ -134,7 +135,7 @@ async def get_newest_version(hass, huuid, include_components): session = async_get_clientsession(hass) - with async_timeout.timeout(15): + with async_timeout.timeout(30): req = await session.post(UPDATER_URL, json=info_object) _LOGGER.info( diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 65049db8c4f..98bf3e6f4dd 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -20,6 +20,10 @@ from .const import ( DISCOVERY_UDN, DISCOVERY_USN, DOMAIN, + DOMAIN_CONFIG, + DOMAIN_COORDINATORS, + DOMAIN_DEVICES, + DOMAIN_LOCAL_IP, LOGGER as _LOGGER, ) from .device import Device @@ -78,10 +82,10 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): conf = config.get(DOMAIN, conf_default) local_ip = await hass.async_add_executor_job(get_local_ip) hass.data[DOMAIN] = { - "config": conf, - "devices": {}, - "coordinators": {}, - "local_ip": conf.get(CONF_LOCAL_IP, local_ip), + DOMAIN_CONFIG: conf, + DOMAIN_COORDINATORS: {}, + DOMAIN_DEVICES: {}, + DOMAIN_LOCAL_IP: conf.get(CONF_LOCAL_IP, local_ip), } # Only start if set up via configuration.yaml. @@ -108,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) raise ConfigEntryNotReady # Save device - hass.data[DOMAIN]["devices"][device.udn] = device + hass.data[DOMAIN][DOMAIN_DEVICES][device.udn] = device # Ensure entry has proper unique_id. if config_entry.unique_id != device.unique_id: @@ -141,8 +145,10 @@ async def async_unload_entry( ) -> bool: """Unload a UPnP/IGD device from a config entry.""" udn = config_entry.data.get(CONFIG_ENTRY_UDN) - del hass.data[DOMAIN]["devices"][udn] - del hass.data[DOMAIN]["coordinators"][udn] + if udn in hass.data[DOMAIN][DOMAIN_DEVICES]: + del hass.data[DOMAIN][DOMAIN_DEVICES][udn] + if udn in hass.data[DOMAIN][DOMAIN_COORDINATORS]: + del hass.data[DOMAIN][DOMAIN_COORDINATORS][udn] _LOGGER.debug("Deleting sensors") return await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 6d85ba94270..0c57a2c243e 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -20,6 +20,7 @@ from .const import ( # pylint: disable=unused-import DISCOVERY_UDN, DISCOVERY_USN, DOMAIN, + DOMAIN_COORDINATORS, LOGGER as _LOGGER, ) from .device import Device @@ -221,7 +222,7 @@ class UpnpOptionsFlowHandler(config_entries.OptionsFlow): """Manage the options.""" if user_input is not None: udn = self.config_entry.data.get(CONFIG_ENTRY_UDN) - coordinator = self.hass.data[DOMAIN]["coordinators"][udn] + coordinator = self.hass.data[DOMAIN][DOMAIN_COORDINATORS][udn] update_interval_sec = user_input.get( CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index eb0844e2cb0..8256fdd9fc9 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -4,9 +4,14 @@ import logging from homeassistant.const import TIME_SECONDS +LOGGER = logging.getLogger(__package__) + CONF_LOCAL_IP = "local_ip" DOMAIN = "upnp" -LOGGER = logging.getLogger(__package__) +DOMAIN_COORDINATORS = "coordinators" +DOMAIN_DEVICES = "devices" +DOMAIN_LOCAL_IP = "local_ip" +DOMAIN_CONFIG = "config" BYTES_RECEIVED = "bytes_received" BYTES_SENT = "bytes_sent" PACKETS_RECEIVED = "packets_received" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 05113b8f9f6..c4a81db1ff4 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -20,6 +20,7 @@ from .const import ( DISCOVERY_UDN, DISCOVERY_USN, DOMAIN, + DOMAIN_CONFIG, LOGGER as _LOGGER, PACKETS_RECEIVED, PACKETS_SENT, @@ -40,8 +41,8 @@ class Device: """Discover UPnP/IGD devices.""" _LOGGER.debug("Discovering UPnP/IGD devices") local_ip = None - if DOMAIN in hass.data and "config" in hass.data[DOMAIN]: - local_ip = hass.data[DOMAIN]["config"].get(CONF_LOCAL_IP) + if DOMAIN in hass.data and DOMAIN_CONFIG in hass.data[DOMAIN]: + local_ip = hass.data[DOMAIN][DOMAIN_CONFIG].get(CONF_LOCAL_IP) if local_ip: local_ip = IPv4Address(local_ip) diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index aea0ec40460..5a34405e0c1 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,6 +1,6 @@ """Support for UPnP/IGD Sensors.""" from datetime import timedelta -from typing import Mapping +from typing import Any, Mapping from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND @@ -18,6 +18,8 @@ from .const import ( DATA_RATE_PACKETS_PER_SECOND, DEFAULT_SCAN_INTERVAL, DOMAIN, + DOMAIN_COORDINATORS, + DOMAIN_DEVICES, KIBIBYTE, LOGGER as _LOGGER, PACKETS_RECEIVED, @@ -84,9 +86,9 @@ async def async_setup_entry( udn = data[CONFIG_ENTRY_UDN] else: # any device will do - udn = list(hass.data[DOMAIN]["devices"].keys())[0] + udn = list(hass.data[DOMAIN][DOMAIN_DEVICES].keys())[0] - device: Device = hass.data[DOMAIN]["devices"][udn] + device: Device = hass.data[DOMAIN][DOMAIN_DEVICES][udn] update_interval_sec = config_entry.options.get( CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL @@ -94,7 +96,7 @@ async def async_setup_entry( update_interval = timedelta(seconds=update_interval_sec) _LOGGER.debug("update_interval: %s", update_interval) _LOGGER.debug("Adding sensors") - coordinator = DataUpdateCoordinator( + coordinator = DataUpdateCoordinator[Mapping[str, Any]]( hass, _LOGGER, name=device.name, @@ -102,7 +104,7 @@ async def async_setup_entry( update_interval=update_interval, ) await coordinator.async_refresh() - hass.data[DOMAIN]["coordinators"][udn] = coordinator + hass.data[DOMAIN][DOMAIN_COORDINATORS][udn] = coordinator sensors = [ RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), @@ -122,7 +124,7 @@ class UpnpSensor(Entity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: DataUpdateCoordinator[Mapping[str, Any]], device: Device, sensor_type: Mapping[str, str], update_multiplier: int = 2, @@ -169,7 +171,7 @@ class UpnpSensor(Entity): return self._sensor_type["unit"] @property - def device_info(self) -> Mapping[str, any]: + def device_info(self) -> Mapping[str, Any]: """Get device info.""" return { "connections": {(dr.CONNECTION_UPNP, self._device.udn)}, diff --git a/homeassistant/components/vacuum/translations/es.json b/homeassistant/components/vacuum/translations/es.json index 0cf61c498f2..87a79a4e5da 100644 --- a/homeassistant/components/vacuum/translations/es.json +++ b/homeassistant/components/vacuum/translations/es.json @@ -16,7 +16,7 @@ "state": { "_": { "cleaning": "Limpiando", - "docked": "En base", + "docked": "En la base", "error": "Error", "idle": "Inactivo", "off": "Apagado", diff --git a/homeassistant/components/vesync/translations/es.json b/homeassistant/components/vesync/translations/es.json index 9eac2f6155d..f4d94672f48 100644 --- a/homeassistant/components/vesync/translations/es.json +++ b/homeassistant/components/vesync/translations/es.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Contrase\u00f1a", - "username": "Direcci\u00f3n de correo electr\u00f3nico" + "username": "Correo electr\u00f3nico" }, "title": "Introduzca el nombre de usuario y la contrase\u00f1a" } diff --git a/homeassistant/components/vilfo/translations/es.json b/homeassistant/components/vilfo/translations/es.json index 97f4b8d417e..0fbfed923cf 100644 --- a/homeassistant/components/vilfo/translations/es.json +++ b/homeassistant/components/vilfo/translations/es.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "access_token": "Token de acceso para la API del Router Vilfo", - "host": "Nombre de host o IP del router" + "access_token": "Token de acceso", + "host": "Host" }, "description": "Configure la integraci\u00f3n del Router Vilfo. Necesita su nombre de host/IP del Router Vilfo y un token de acceso a la API. Para obtener informaci\u00f3n adicional sobre esta integraci\u00f3n y c\u00f3mo obtener esos detalles, visite: https://www.home-assistant.io/integrations/vilfo", "title": "Conectar con el Router Vilfo" diff --git a/homeassistant/components/vizio/translations/es.json b/homeassistant/components/vizio/translations/es.json index ad496be3836..9daaa0973b7 100644 --- a/homeassistant/components/vizio/translations/es.json +++ b/homeassistant/components/vizio/translations/es.json @@ -7,8 +7,8 @@ "error": { "cannot_connect": "No se pudo conectar", "complete_pairing_failed": "No se pudo completar el emparejamiento. Aseg\u00farate de que el PIN que has proporcionado es correcto y que el televisor sigue encendido y conectado a la red antes de volver a enviarlo.", - "host_exists": "El host ya est\u00e1 configurado.", - "name_exists": "Nombre ya configurado." + "host_exists": "Ya existe un VIZIO SmartCast Device configurado con ese host.", + "name_exists": "Ya existe un VIZIO SmartCast Device configurado con ese nombre." }, "step": { "pair_tv": { @@ -30,11 +30,11 @@ "data": { "access_token": "Token de acceso", "device_class": "Tipo de dispositivo", - "host": "< Host / IP > : ", + "host": "Host", "name": "Nombre" }, "description": "El token de acceso solo se necesita para las televisiones. Si est\u00e1s configurando una televisi\u00f3n y a\u00fan no tienes un token de acceso, d\u00e9jalo en blanco para iniciar el proceso de sincronizaci\u00f3n.", - "title": "Configurar el cliente de Vizio SmartCast" + "title": "VIZIO SmartCast Device" } } }, diff --git a/homeassistant/components/vizio/translations/no.json b/homeassistant/components/vizio/translations/no.json index ab585dcdcf3..0f341ce63b5 100644 --- a/homeassistant/components/vizio/translations/no.json +++ b/homeassistant/components/vizio/translations/no.json @@ -1,12 +1,14 @@ { "config": { "abort": { + "already_configured_device": "Enheten er allerede konfigurert", "updated_entry": "Dette innlegget har allerede v\u00e6rt oppsett, men navnet, apps, og/eller alternativer som er definert i konfigurasjon som ikke stemmer med det som tidligere er importert konfigurasjon, s\u00e5 konfigurasjonen innlegget har blitt oppdatert i henhold til dette." }, "error": { + "cannot_connect": "Tilkobling mislyktes.", "complete_pairing_failed": "Kan ikke fullf\u00f8re sammenkoblingen. Forsikre deg om at PIN-koden du oppga er riktig, og at TV-en fortsatt er p\u00e5 og tilkoblet nettverket f\u00f8r du sender inn p\u00e5 nytt.", - "host_exists": "VIZIO-enhet med spesifisert vert allerede konfigurert.", - "name_exists": "VIZIO-enhet med spesifisert navn allerede konfigurert." + "host_exists": "VIZIO SmartCast-enhet with specified host already configured.", + "name_exists": "VIZIO SmartCast-enhet with specified name already configured." }, "step": { "pair_tv": { @@ -17,9 +19,11 @@ "title": "Fullf\u00f8r sammenkoblingsprosess" }, "pairing_complete": { + "description": "VIZIO SmartCast-enhet er n\u00e5 koblet til Home Assistant.", "title": "Sammenkoblingen fullf\u00f8rt" }, "pairing_complete_import": { + "description": "Din VIZIO SmartCast-enhet er n\u00e5 koblet til VIZIO SmartCast-enhet . \n\n Tilgangstoken er '** {access_token} **'.", "title": "Sammenkoblingen fullf\u00f8rt" }, "user": { @@ -29,6 +33,7 @@ "host": "Vert", "name": "Navn" }, + "description": "En Tilgangstoken er bare n\u00f8dvendig for TV-er. Hvis du konfigurerer en TV og ikke har en Tilgangstoken enn\u00e5, la den st\u00e5 tom for \u00e5 g\u00e5 gjennom en sammenkoblingsprosess.", "title": "VIZIO SmartCast-enhet" } } @@ -41,7 +46,8 @@ "include_or_exclude": "Inkluder eller ekskludere apper?", "volume_step": "St\u00f8rrelse p\u00e5 volum trinn" }, - "description": "Hvis du har en Smart-TV, kan du eventuelt filtrere kildelisten ved \u00e5 velge hvilke apper som skal inkluderes eller utelates i kildelisten." + "description": "Hvis du har en Smart-TV, kan du eventuelt filtrere kildelisten ved \u00e5 velge hvilke apper som skal inkluderes eller utelates i kildelisten.", + "title": "Oppdater VIZIO SmartCast-enhet Alternativer" } } } diff --git a/homeassistant/components/volumio/__init__.py b/homeassistant/components/volumio/__init__.py index 823533336ba..8d171cab9d2 100644 --- a/homeassistant/components/volumio/__init__.py +++ b/homeassistant/components/volumio/__init__.py @@ -1 +1,59 @@ -"""The volumio component.""" +"""The Volumio integration.""" +import asyncio + +from pyvolumio import CannotConnectError, Volumio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN + +PLATFORMS = ["media_player"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Volumio component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Volumio from a config entry.""" + + volumio = Volumio( + entry.data[CONF_HOST], entry.data[CONF_PORT], async_get_clientsession(hass) + ) + try: + info = await volumio.get_system_version() + except CannotConnectError as error: + raise ConfigEntryNotReady from error + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DATA_VOLUMIO: volumio, + DATA_INFO: info, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py new file mode 100644 index 00000000000..950a161a5c3 --- /dev/null +++ b/homeassistant/components/volumio/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow for Volumio integration.""" +import logging +from typing import Optional + +from pyvolumio import CannotConnectError, Volumio +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=3000): int} +) + + +async def validate_input(hass, host, port): + """Validate the user input allows us to connect.""" + volumio = Volumio(host, port, async_get_clientsession(hass)) + + try: + return await volumio.get_system_info() + except CannotConnectError as error: + raise CannotConnect from error + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Volumio.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize flow.""" + self._host: Optional[str] = None + self._port: Optional[int] = None + self._name: Optional[str] = None + self._uuid: Optional[str] = None + + @callback + def _async_get_entry(self): + return self.async_create_entry( + title=self._name, + data={ + CONF_NAME: self._name, + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_ID: self._uuid, + }, + ) + + async def _set_uid_and_abort(self): + await self.async_set_unique_id(self._uuid) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NAME: self._name, + } + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + info = None + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] + try: + info = await validate_input(self.hass, self._host, self._port) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if info is not None: + self._name = info.get("name", self._host) + self._uuid = info.get("id") + if self._uuid is not None: + await self._set_uid_and_abort() + + return self._async_get_entry() + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + """Handle zeroconf discovery.""" + self._host = discovery_info["host"] + self._port = int(discovery_info["port"]) + self._name = discovery_info["properties"]["volumioName"] + self._uuid = discovery_info["properties"]["UUID"] + + await self._set_uid_and_abort() + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm(self, user_input=None): + """Handle user-confirmation of discovered node.""" + if user_input is not None: + try: + await validate_input(self.hass, self._host, self._port) + return self._async_get_entry() + except CannotConnect: + return self.async_abort(reason="cannot_connect") + + return self.async_show_form( + step_id="discovery_confirm", description_placeholders={"name": self._name} + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/volumio/const.py b/homeassistant/components/volumio/const.py new file mode 100644 index 00000000000..608c029a85e --- /dev/null +++ b/homeassistant/components/volumio/const.py @@ -0,0 +1,6 @@ +"""Constants for the Volumio integration.""" + +DOMAIN = "volumio" + +DATA_INFO = "info" +DATA_VOLUMIO = "volumio" diff --git a/homeassistant/components/volumio/manifest.json b/homeassistant/components/volumio/manifest.json index 7fed8811600..c5d14859f05 100644 --- a/homeassistant/components/volumio/manifest.json +++ b/homeassistant/components/volumio/manifest.json @@ -2,5 +2,8 @@ "domain": "volumio", "name": "Volumio", "documentation": "https://www.home-assistant.io/integrations/volumio", - "codeowners": [] -} + "codeowners": ["@OnFreund"], + "config_flow": true, + "zeroconf": ["_Volumio._tcp.local."], + "requirements": ["pyvolumio==0.1.1"] +} \ No newline at end of file diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 58b3b4a04ba..d471f283ef1 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -3,15 +3,10 @@ Volumio Platform. Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ -import asyncio from datetime import timedelta import logging -import socket -import aiohttp -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 ( MEDIA_TYPE_MUSIC, SUPPORT_CLEAR_PLAYLIST, @@ -28,29 +23,19 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_STEP, ) from homeassistant.const import ( - CONF_HOST, + CONF_ID, CONF_NAME, - CONF_PORT, - HTTP_OK, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle +from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN + _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = "localhost" -DEFAULT_NAME = "Volumio" -DEFAULT_PORT = 3000 - -DATA_VOLUMIO = "volumio" - -TIMEOUT = 10 - SUPPORT_VOLUMIO = ( SUPPORT_PAUSE | SUPPORT_VOLUME_SET @@ -68,91 +53,58 @@ SUPPORT_VOLUMIO = ( PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } -) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Volumio media player platform.""" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Volumio platform.""" - if DATA_VOLUMIO not in hass.data: - hass.data[DATA_VOLUMIO] = {} + data = hass.data[DOMAIN][config_entry.entry_id] + volumio = data[DATA_VOLUMIO] + info = data[DATA_INFO] + uid = config_entry.data[CONF_ID] + name = config_entry.data[CONF_NAME] - # This is a manual configuration? - if discovery_info is None: - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - else: - name = "{} ({})".format(DEFAULT_NAME, discovery_info.get("hostname")) - host = discovery_info.get("host") - port = discovery_info.get("port") - - # Only add a device once, so discovered devices do not override manual - # config. - ip_addr = socket.gethostbyname(host) - if ip_addr in hass.data[DATA_VOLUMIO]: - return - - entity = Volumio(name, host, port, hass) - - hass.data[DATA_VOLUMIO][ip_addr] = entity + entity = Volumio(volumio, uid, name, info) async_add_entities([entity]) class Volumio(MediaPlayerEntity): """Volumio Player Object.""" - def __init__(self, name, host, port, hass): + def __init__(self, volumio, uid, name, info): """Initialize the media player.""" - self.host = host - self.port = port - self.hass = hass - self._url = "{}:{}".format(host, str(port)) + self._volumio = volumio + self._uid = uid self._name = name + self._info = info self._state = {} - self._lastvol = self._state.get("volume", 0) self._playlists = [] self._currentplaylist = None - async def send_volumio_msg(self, method, params=None): - """Send message.""" - url = f"http://{self.host}:{self.port}/api/v1/{method}/" - - _LOGGER.debug("URL: %s params: %s", url, params) - - try: - websession = async_get_clientsession(self.hass) - response = await websession.get(url, params=params) - if response.status == HTTP_OK: - data = await response.json() - else: - _LOGGER.error( - "Query failed, response code: %s Full message: %s", - response.status, - response, - ) - return False - - except (asyncio.TimeoutError, aiohttp.ClientError) as error: - _LOGGER.error( - "Failed communicating with Volumio '%s': %s", self._name, type(error) - ) - return False - - return data - async def async_update(self): """Update state.""" - resp = await self.send_volumio_msg("getState") + self._state = await self._volumio.get_state() await self._async_update_playlists() - if resp is False: - return - self._state = resp.copy() + + @property + def unique_id(self): + """Return the unique id for the entity.""" + return self._uid + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Volumio", + "sw_version": self._info["systemversion"], + "model": self._info["hardware"], + } @property def media_content_type(self): @@ -189,13 +141,7 @@ class Volumio(MediaPlayerEntity): def media_image_url(self): """Image url of current playing media.""" url = self._state.get("albumart", None) - if url is None: - return - if str(url[0:2]).lower() == "ht": - mediaurl = url - else: - mediaurl = f"http://{self.host}:{self.port}{url}" - return mediaurl + return self._volumio.canonic_url(url) @property def media_seek_position(self): @@ -220,11 +166,6 @@ class Volumio(MediaPlayerEntity): """Boolean if volume is currently muted.""" return self._state.get("mute", None) - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def shuffle(self): """Boolean if shuffle is enabled.""" @@ -247,79 +188,61 @@ class Volumio(MediaPlayerEntity): async def async_media_next_track(self): """Send media_next command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "next"}) + await self._volumio.next() async def async_media_previous_track(self): """Send media_previous command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "prev"}) + await self._volumio.previous() async def async_media_play(self): """Send media_play command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "play"}) + await self._volumio.play() async def async_media_pause(self): """Send media_pause command to media player.""" if self._state["trackType"] == "webradio": - await self.send_volumio_msg("commands", params={"cmd": "stop"}) + await self._volumio.stop() else: - await self.send_volumio_msg("commands", params={"cmd": "pause"}) + await self._volumio.pause() async def async_media_stop(self): """Send media_stop command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "stop"}) + await self._volumio.stop() async def async_set_volume_level(self, volume): """Send volume_up command to media player.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": int(volume * 100)} - ) + await self._volumio.set_volume_level(int(volume * 100)) async def async_volume_up(self): """Service to send the Volumio the command for volume up.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": "plus"} - ) + await self._volumio.volume_up() async def async_volume_down(self): """Service to send the Volumio the command for volume down.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": "minus"} - ) + await self._volumio.volume_down() async def async_mute_volume(self, mute): """Send mute command to media player.""" - mutecmd = "mute" if mute else "unmute" if mute: - # mute is implemented as 0 volume, do save last volume level - self._lastvol = self._state["volume"] - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": mutecmd} - ) - return - - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": self._lastvol} - ) + await self._volumio.mute() + else: + await self._volumio.unmute() async def async_set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" - await self.send_volumio_msg( - "commands", params={"cmd": "random", "value": str(shuffle).lower()} - ) + await self._volumio.set_shuffle(shuffle) async def async_select_source(self, source): - """Choose a different available playlist and play it.""" + """Choose an available playlist and play it.""" + await self._volumio.play_playlist(source) self._currentplaylist = source - await self.send_volumio_msg( - "commands", params={"cmd": "playplaylist", "name": source} - ) async def async_clear_playlist(self): """Clear players playlist.""" + await self._volumio.clear_playlist() self._currentplaylist = None - await self.send_volumio_msg("commands", params={"cmd": "clearQueue"}) @Throttle(PLAYLIST_UPDATE_INTERVAL) async def _async_update_playlists(self, **kwargs): """Update available Volumio playlists.""" - self._playlists = await self.send_volumio_msg("listplaylists") + self._playlists = await self._volumio.get_playlists() diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json new file mode 100644 index 00000000000..ffa53b2c438 --- /dev/null +++ b/homeassistant/components/volumio/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "discovery_confirm": { + "description": "Do you want to add Volumio (`{name}`) to Home Assistant?", + "title": "Discovered Volumio" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "Cannot connect to discovered Volumio" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/ca.json b/homeassistant/components/volumio/translations/ca.json new file mode 100644 index 00000000000..df63d99095d --- /dev/null +++ b/homeassistant/components/volumio/translations/ca.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "No es pot connectar amb el Volumio descobert" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "discovery_confirm": { + "description": "Vols afegir el Volumio (`{name}`) a Home Assistant?", + "title": "Volumio descobert" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/cs.json b/homeassistant/components/volumio/translations/cs.json new file mode 100644 index 00000000000..c3862ff2f79 --- /dev/null +++ b/homeassistant/components/volumio/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "discovery_confirm": { + "description": "Chcete p\u0159idat Volumio (`{name}`) do Home Assistant?", + "title": "Objeven\u00e9 instance Volumio" + }, + "user": { + "data": { + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/en.json b/homeassistant/components/volumio/translations/en.json new file mode 100644 index 00000000000..5c70f8d4df8 --- /dev/null +++ b/homeassistant/components/volumio/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Cannot connect to discovered Volumio" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "discovery_confirm": { + "description": "Do you want to add Volumio (`{name}`) to Home Assistant?", + "title": "Discovered Volumio" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/es.json b/homeassistant/components/volumio/translations/es.json new file mode 100644 index 00000000000..8aa4a870a36 --- /dev/null +++ b/homeassistant/components/volumio/translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se puede conectar con el Volumio descubierto" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, + "step": { + "discovery_confirm": { + "description": "\u00bfQuieres a\u00f1adir Volumio (`{name}`) a Home Assistant?", + "title": "Volumio descubierto" + }, + "user": { + "data": { + "host": "Host", + "port": "Puerto" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/it.json b/homeassistant/components/volumio/translations/it.json new file mode 100644 index 00000000000..f53ea6005b2 --- /dev/null +++ b/homeassistant/components/volumio/translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi al Volumio rilevato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "discovery_confirm": { + "description": "Vuoi aggiungere Volumio (`{name}`) a Home Assistant?", + "title": "Rilevato Volumio" + }, + "user": { + "data": { + "host": "Host", + "port": "Porta" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/lb.json b/homeassistant/components/volumio/translations/lb.json new file mode 100644 index 00000000000..65a32a4a795 --- /dev/null +++ b/homeassistant/components/volumio/translations/lb.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert", + "cannot_connect": "Kann sech net mam enteckte Volumio verbannen" + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "discovery_confirm": { + "description": "Soll de Volumio (`{name}`) am Home Assistant dob\u00e4i gesaat ginn?", + "title": "Entdeckte Volumio" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/no.json b/homeassistant/components/volumio/translations/no.json new file mode 100644 index 00000000000..60b5fc38e68 --- /dev/null +++ b/homeassistant/components/volumio/translations/no.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Kan ikke koble til oppdaget Volumio" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "unknown": "Uventet feil" + }, + "step": { + "discovery_confirm": { + "description": "Vil du legge Volumio (` {name} `) til Home Assistant?", + "title": "Oppdaget Volumio" + }, + "user": { + "data": { + "host": "Vert", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/pt.json b/homeassistant/components/volumio/translations/pt.json similarity index 59% rename from homeassistant/components/linky/translations/pt.json rename to homeassistant/components/volumio/translations/pt.json index 54619af958e..f681da4210f 100644 --- a/homeassistant/components/linky/translations/pt.json +++ b/homeassistant/components/volumio/translations/pt.json @@ -3,8 +3,8 @@ "step": { "user": { "data": { - "password": "Palavra-passe", - "username": "O email" + "host": "Servidor", + "port": "Porta" } } } diff --git a/homeassistant/components/volumio/translations/ru.json b/homeassistant/components/volumio/translations/ru.json new file mode 100644 index 00000000000..84267ff4a4e --- /dev/null +++ b/homeassistant/components/volumio/translations/ru.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u043c\u0443 Volumio." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "discovery_confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c Volumio `{name}`?", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u044b\u0439 Volumio" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/uk.json b/homeassistant/components/volumio/translations/uk.json new file mode 100644 index 00000000000..58947e14e4f --- /dev/null +++ b/homeassistant/components/volumio/translations/uk.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041f\u043e\u043c\u0438\u043b\u043a\u0430 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044f", + "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" + }, + "step": { + "discovery_confirm": { + "title": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e Volumio" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/zh-Hant.json b/homeassistant/components/volumio/translations/zh-Hant.json new file mode 100644 index 00000000000..48f3ad6d172 --- /dev/null +++ b/homeassistant/components/volumio/translations/zh-Hant.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u5df2\u63a2\u7d22\u5230\u7684 Volumio" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u65b0\u589e Volumio (`{name}`) \u81f3 Home Assistant\uff1f", + "title": "\u5df2\u641c\u7d22\u5230\u7684 Volumio" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 232ecf477f7..7b7dffbef18 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -54,6 +54,7 @@ RESOURCES = [ "odometer", "trip_meter1", "trip_meter2", + "average_speed", "fuel_amount", "fuel_amount_level", "average_fuel_consumption", @@ -70,6 +71,7 @@ RESOURCES = [ "last_trip", "is_engine_running", "doors_hood_open", + "doors_tailgate_open", "doors_front_left_door_open", "doors_front_right_door_open", "doors_rear_left_door_open", diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json index c16ad0e4858..822e7eef5a8 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -2,6 +2,6 @@ "domain": "volvooncall", "name": "Volvo On Call", "documentation": "https://www.home-assistant.io/integrations/volvooncall", - "requirements": ["volvooncall==0.8.7"], + "requirements": ["volvooncall==0.8.12"], "codeowners": [] } diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index aa5a2a9c79d..1cbf844b289 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -162,6 +162,7 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity): def update_sources(self): """Update list of sources from current source, apps, inputs and configured list.""" + source_list = self._source_list self._source_list = {} conf_sources = self._customize[CONF_SOURCES] @@ -206,6 +207,8 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity): or any(word in app["id"] for word in conf_sources) ): self._source_list["Live TV"] = app + if not self._source_list and source_list: + self._source_list = source_list @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) async def async_update(self): diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index e08e82b3269..7bb3371c153 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.4.43"], + "requirements": ["pywemo==0.4.45"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/homeassistant/components/wemo/translations/tr.json b/homeassistant/components/wemo/translations/tr.json new file mode 100644 index 00000000000..411a536ceed --- /dev/null +++ b/homeassistant/components/wemo/translations/tr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "no_devices_found": "A\u011fda Wemo cihaz\u0131 bulunamad\u0131." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wiffi/manifest.json b/homeassistant/components/wiffi/manifest.json index 5be1286ad6f..fa06699ac08 100644 --- a/homeassistant/components/wiffi/manifest.json +++ b/homeassistant/components/wiffi/manifest.json @@ -3,7 +3,7 @@ "name": "Wiffi", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wiffi", - "requirements": ["wiffi==1.0.0"], + "requirements": ["wiffi==1.0.1"], "dependencies": [], "codeowners": [ "@mampfes" diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json index d3059a49497..97205e6fc9d 100644 --- a/homeassistant/components/wirelesstag/manifest.json +++ b/homeassistant/components/wirelesstag/manifest.json @@ -2,6 +2,6 @@ "domain": "wirelesstag", "name": "Wireless Sensor Tags", "documentation": "https://www.home-assistant.io/integrations/wirelesstag", - "requirements": ["wirelesstagpy==0.4.0"], + "requirements": ["wirelesstagpy==0.4.1"], "codeowners": [] } diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 0e2ff7c164f..2f3f4bf849a 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -582,7 +582,9 @@ class DataManager: update_interval=timedelta(minutes=120), update_method=self.async_subscribe_webhook, ) - self.poll_data_update_coordinator = DataUpdateCoordinator( + self.poll_data_update_coordinator = DataUpdateCoordinator[ + Dict[MeasureType, Any] + ]( hass, _LOGGER, name="poll_data_update_coordinator", @@ -924,6 +926,12 @@ class BaseWithingsSensor(Entity): if self._attribute.update_type == UpdateType.POLL: return self._data_manager.poll_data_update_coordinator.last_update_success + if self._attribute.update_type == UpdateType.WEBHOOK: + return self._data_manager.webhook_config.enabled and ( + self._attribute.measurement + in self._data_manager.webhook_update_coordinator.data + ) + return True @property diff --git a/homeassistant/components/withings/translations/es.json b/homeassistant/components/withings/translations/es.json index 392f300260a..e59b6e96775 100644 --- a/homeassistant/components/withings/translations/es.json +++ b/homeassistant/components/withings/translations/es.json @@ -25,7 +25,7 @@ }, "reauth": { "description": "El perfil \"{profile}\" debe volver a autenticarse para continuar recibiendo datos de Withings.", - "title": "Volver a autenticar a {profile}" + "title": "Re-autentificar el perfil" } } } diff --git a/homeassistant/components/withings/translations/nl.json b/homeassistant/components/withings/translations/nl.json index 0fe8153cbe4..4f382f02a57 100644 --- a/homeassistant/components/withings/translations/nl.json +++ b/homeassistant/components/withings/translations/nl.json @@ -17,6 +17,9 @@ }, "description": "Welk profiel hebt u op de website van Withings selecteren? Het is belangrijk dat de profielen overeenkomen, anders worden gegevens verkeerd gelabeld.", "title": "Gebruikersprofiel." + }, + "reauth": { + "title": "Profiel opnieuw verifi\u00ebren" } } } diff --git a/homeassistant/components/withings/translations/no.json b/homeassistant/components/withings/translations/no.json index 14fb9573f8b..2b39f8fceab 100644 --- a/homeassistant/components/withings/translations/no.json +++ b/homeassistant/components/withings/translations/no.json @@ -18,7 +18,7 @@ }, "profile": { "data": { - "profile": "Profil" + "profile": "Profil navn" }, "description": "Oppgi et unikt profilnavn for disse dataene. Dette er vanligvis navnet p\u00e5 profilen du valgte i forrige trinn.", "title": "Brukerprofil." diff --git a/homeassistant/components/withings/translations/pt.json b/homeassistant/components/withings/translations/pt.json index 0a1f02335cc..b80d6630c35 100644 --- a/homeassistant/components/withings/translations/pt.json +++ b/homeassistant/components/withings/translations/pt.json @@ -5,6 +5,9 @@ "data": { "profile": "Perfil" } + }, + "reauth": { + "title": "Re-autenticar Perfil" } } } diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 70d14895fbc..5cc2453d78c 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -107,7 +107,7 @@ def wled_exception_handler(func): return handler -class WLEDDataUpdateCoordinator(DataUpdateCoordinator): +class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" def __init__( diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py new file mode 100644 index 00000000000..cce9d542446 --- /dev/null +++ b/homeassistant/components/wolflink/__init__.py @@ -0,0 +1,103 @@ +"""The Wolf SmartSet Service integration.""" +from datetime import timedelta +import logging + +from httpcore import ConnectError +from wolf_smartset.token_auth import InvalidAuth +from wolf_smartset.wolf_client import WolfClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + COORDINATOR, + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, + PARAMETERS, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Wolf SmartSet Service component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Wolf SmartSet Service from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + device_name = entry.data[DEVICE_NAME] + device_id = entry.data[DEVICE_ID] + gateway_id = entry.data[DEVICE_GATEWAY] + _LOGGER.debug( + "Setting up wolflink integration for device: %s (id: %s, gateway: %s)", + device_name, + device_id, + gateway_id, + ) + + wolf_client = WolfClient(username, password) + + parameters = await fetch_parameters(wolf_client, gateway_id, device_id) + + async def async_update_data(): + """Update all stored entities for Wolf SmartSet.""" + try: + values = await wolf_client.fetch_value(gateway_id, device_id, parameters) + return {v.value_id: v.value for v in values} + except ConnectError as exception: + raise UpdateFailed(f"Error communicating with API: {exception}") + except InvalidAuth: + raise UpdateFailed("Invalid authentication during update.") + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="wolflink", + update_method=async_update_data, + update_interval=timedelta(minutes=1), + ) + + await coordinator.async_refresh() + + hass.data[DOMAIN][entry.entry_id] = {} + hass.data[DOMAIN][entry.entry_id][PARAMETERS] = parameters + hass.data[DOMAIN][entry.entry_id][COORDINATOR] = coordinator + hass.data[DOMAIN][entry.entry_id][DEVICE_ID] = device_id + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor") + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def fetch_parameters(client: WolfClient, gateway_id: int, device_id: int): + """ + Fetch all available parameters with usage of WolfClient. + + By default Reglertyp entity is removed because API will not provide value for this parameter. + """ + try: + fetched_parameters = await client.fetch_parameters(gateway_id, device_id) + return [param for param in fetched_parameters if param.name != "Reglertyp"] + except ConnectError as exception: + raise UpdateFailed(f"Error communicating with API: {exception}") + except InvalidAuth: + raise UpdateFailed("Invalid authentication during update.") diff --git a/homeassistant/components/wolflink/config_flow.py b/homeassistant/components/wolflink/config_flow.py new file mode 100644 index 00000000000..f54789cef78 --- /dev/null +++ b/homeassistant/components/wolflink/config_flow.py @@ -0,0 +1,93 @@ +"""Config flow for Wolf SmartSet Service integration.""" +import logging + +from httpcore import ConnectError +import voluptuous as vol +from wolf_smartset.token_auth import InvalidAuth +from wolf_smartset.wolf_client import WolfClient + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import ( # pylint:disable=unused-import + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Wolf SmartSet Service.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize with empty username and password.""" + self.username = None + self.password = None + self.fetched_systems = None + + async def async_step_user(self, user_input=None): + """Handle the initial step to get connection parameters.""" + errors = {} + if user_input is not None: + wolf_client = WolfClient( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + try: + self.fetched_systems = await wolf_client.fetch_system_list() + except ConnectError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.username = user_input[CONF_USERNAME] + self.password = user_input[CONF_PASSWORD] + return await self.async_step_device() + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) + + async def async_step_device(self, user_input=None): + """Allow user to select device from devices connected to specified account.""" + errors = {} + if user_input is not None: + device_name = user_input[DEVICE_NAME] + system = [ + device for device in self.fetched_systems if device.name == device_name + ] + device_id = system[0].id + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[DEVICE_NAME], + data={ + CONF_USERNAME: self.username, + CONF_PASSWORD: self.password, + DEVICE_NAME: device_name, + DEVICE_GATEWAY: system[0].gateway, + DEVICE_ID: device_id, + }, + ) + + data_schema = vol.Schema( + { + vol.Required(DEVICE_NAME): vol.In( + [info.name for info in self.fetched_systems] + ) + } + ) + return self.async_show_form( + step_id="device", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/wolflink/const.py b/homeassistant/components/wolflink/const.py new file mode 100644 index 00000000000..ac5bbad48dc --- /dev/null +++ b/homeassistant/components/wolflink/const.py @@ -0,0 +1,93 @@ +"""Constants for the Wolf SmartSet Service integration.""" + +DOMAIN = "wolflink" + +COORDINATOR = "coordinator" +PARAMETERS = "parameters" +DEVICE_ID = "device_id" +DEVICE_GATEWAY = "device_gateway" +DEVICE_NAME = "device_name" + +STATES = { + "Ein": "ein", + "Deaktiviert": "deaktiviert", + "Aus": "aus", + "Standby": "standby", + "Auto": "auto", + "Permanent": "permanent", + "Initialisierung": "initialisierung", + "Antilegionellenfunktion": "antilegionellenfunktion", + "Fernschalter ein": "fernschalter_ein", + "1x Warmwasser": "1_x_warmwasser", + "Bereit, keine Ladung": "bereit_keine_ladung", + "Solarbetrieb": "solarbetrieb", + "Reduzierter Betrieb": "reduzierter_betrieb", + "SmartHome": "smart_home", + "SmartGrid": "smart_grid", + "Ruhekontakt": "ruhekontakt", + "Vorspülen": "vorspulen", + "Zünden": "zunden", + "Stabilisierung": "stabilisierung", + "Ventilprüfung": "ventilprufung", + "Nachspülen": "nachspulen", + "Softstart": "softstart", + "Taktsperre": "taktsperre", + "Betrieb ohne Brenner": "betrieb_ohne_brenner", + "Abgasklappe": "abgasklappe", + "Störung": "storung", + "Gradienten Überwachung": "gradienten_uberwachung", + "Gasdruck": "gasdruck", + "Spreizung hoch": "spreizung_hoch", + "Spreizung KF": "spreizung_kf", + "Test": "test", + "Start": "start", + "Frost Heizkreis": "frost_heizkreis", + "Frost Warmwasser": "frost_warmwasser", + "Schornsteinfeger": "schornsteinfeger", + "Kombibetrieb": "kombibetrieb", + "Parallelbetrieb": "parallelbetrieb", + "Warmwasserbetrieb": "warmwasserbetrieb", + "Warmwassernachlauf": "warmwassernachlauf", + "Mindest-Kombizeit": "mindest_kombizeit", + "Heizbetrieb": "heizbetrieb", + "Nachlauf Heizkreispumpe": "nachlauf_heizkreispumpe", + "Frostschutz": "frostschutz", + "Kaskadenbetrieb": "kaskadenbetrieb", + "GLT-Betrieb": "glt_betrieb", + "Kalibration": "kalibration", + "Kalibration Heizbetrieb": "kalibration_heizbetrieb", + "Kalibration Warmwasserbetrieb": "kalibration_warmwasserbetrieb", + "Kalibration Kombibetrieb": "kalibration_kombibetrieb", + "Warmwasser Schnellstart": "warmwasser_schnellstart", + "Externe Deaktivierung": "externe_deaktivierung", + "Heizung": "heizung", + "Warmwasser": "warmwasser", + "Kombigerät": "kombigerat", + "Kombigerät mit Solareinbindung": "kombigerat_mit_solareinbindung", + "Heizgerät mit Speicher": "heizgerat_mit_speicher", + "Nur Heizgerät": "nur_heizgerat", + "Aktiviert": "ktiviert", + "Sparen": "sparen", + "Estrichtrocknung": "estrichtrocknung", + "Telefonfernschalter": "telefonfernschalter", + "Partymodus": "partymodus", + "Urlaubsmodus": "urlaubsmodus", + "Automatik ein": "automatik_ein", + "Automatik aus": "automatik_aus", + "Permanentbetrieb": "permanentbetrieb", + "Sparbetrieb": "sparbetrieb", + "AutoOnCool": "auto_on_cool", + "AutoOffCool": "auto_off_cool", + "PermCooling": "perm_cooling", + "Absenkbetrieb": "absenkbetrieb", + "Eco": "eco", + "Absenkstop": "absenkstop", + "AT Abschaltung": "at_abschaltung", + "RT Abschaltung": "rt_abschaltung", + "AT Frostschutz": "at_frostschutz", + "RT Frostschutz": "rt_frostschutz", + "DHWPrior": "dhw_prior", + "Cooling": "cooling", + "TPW": "tpw", + "Warmwasservorrang": "warmwasservorrang", +} diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json new file mode 100644 index 00000000000..c188c090369 --- /dev/null +++ b/homeassistant/components/wolflink/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "wolflink", + "name": "Wolf SmartSet Service", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/wolflink", + "requirements": ["wolf_smartset==0.1.4"], + "codeowners": ["@adamkrol93"] +} diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py new file mode 100644 index 00000000000..a9deced9e91 --- /dev/null +++ b/homeassistant/components/wolflink/sensor.py @@ -0,0 +1,182 @@ +"""The Wolf SmartSet sensors.""" +import logging + +from wolf_smartset.models import ( + HoursParameter, + ListItemParameter, + Parameter, + PercentageParameter, + Pressure, + SimpleParameter, + Temperature, +) + +from homeassistant.components.wolflink.const import ( + COORDINATOR, + DEVICE_ID, + DOMAIN, + PARAMETERS, + STATES, +) +from homeassistant.const import ( + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PRESSURE_BAR, + TEMP_CELSIUS, + TIME_HOURS, +) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up all entries for Wolf Platform.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + parameters = hass.data[DOMAIN][config_entry.entry_id][PARAMETERS] + device_id = hass.data[DOMAIN][config_entry.entry_id][DEVICE_ID] + + entities = [] + for parameter in parameters: + if isinstance(parameter, Temperature): + entities.append(WolfLinkTemperature(coordinator, parameter, device_id)) + if isinstance(parameter, Pressure): + entities.append(WolfLinkPressure(coordinator, parameter, device_id)) + if isinstance(parameter, PercentageParameter): + entities.append(WolfLinkPercentage(coordinator, parameter, device_id)) + if isinstance(parameter, ListItemParameter): + entities.append(WolfLinkState(coordinator, parameter, device_id)) + if isinstance(parameter, HoursParameter): + entities.append(WolfLinkHours(coordinator, parameter, device_id)) + if isinstance(parameter, SimpleParameter): + entities.append(WolfLinkSensor(coordinator, parameter, device_id)) + + async_add_entities(entities, True) + + +class WolfLinkSensor(Entity): + """Base class for all Wolf entities.""" + + def __init__(self, coordinator, wolf_object: Parameter, device_id): + """Initialize.""" + self.coordinator = coordinator + self.wolf_object = wolf_object + self.device_id = device_id + + @property + def name(self): + """Return the name.""" + return f"{self.wolf_object.name}" + + @property + def state(self): + """Return the state.""" + return self.coordinator.data[self.wolf_object.value_id] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + "parameter_id": self.wolf_object.parameter_id, + "value_id": self.wolf_object.value_id, + "parent": self.wolf_object.parent, + } + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return f"{self.device_id}:{self.wolf_object.parameter_id}" + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the sensor.""" + await self.coordinator.async_request_refresh() + _LOGGER.debug("Updating %s", self.coordinator.data[self.wolf_object.value_id]) + + +class WolfLinkHours(WolfLinkSensor): + """Class for hour based entities.""" + + @property + def icon(self): + """Icon to display in the front Aend.""" + return "mdi:clock" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return TIME_HOURS + + +class WolfLinkTemperature(WolfLinkSensor): + """Class for temperature based entities.""" + + @property + def device_class(self): + """Return the device_class.""" + return DEVICE_CLASS_TEMPERATURE + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return TEMP_CELSIUS + + +class WolfLinkPressure(WolfLinkSensor): + """Class for pressure based entities.""" + + @property + def device_class(self): + """Return the device_class.""" + return DEVICE_CLASS_PRESSURE + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return PRESSURE_BAR + + +class WolfLinkPercentage(WolfLinkSensor): + """Class for percentage based entities.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self.wolf_object.unit + + +class WolfLinkState(WolfLinkSensor): + """Class for entities which has defined list of state.""" + + @property + def device_class(self): + """Return the device class.""" + return "wolflink__state" + + @property + def state(self): + """Return the state converting with supported values.""" + state = self.coordinator.data[self.wolf_object.value_id] + resolved_state = [ + item for item in self.wolf_object.items if item.value == int(state) + ] + if resolved_state: + resolved_name = resolved_state[0].name + return STATES.get(resolved_name, resolved_name) + return state diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json new file mode 100644 index 00000000000..4a98f93318f --- /dev/null +++ b/homeassistant/components/wolflink/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "title": "WOLF SmartSet connection" + }, + "device": { + "data": { + "device_name": "Device" + }, + "title": "Select WOLF device" + } + } + } +} diff --git a/homeassistant/components/wolflink/strings.sensor.json b/homeassistant/components/wolflink/strings.sensor.json new file mode 100644 index 00000000000..2ce7df6fae5 --- /dev/null +++ b/homeassistant/components/wolflink/strings.sensor.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "ein": "Enabled", + "deaktiviert": "Inactive", + "aus": "Disabled", + "standby": "Standby", + "auto": "Auto", + "permanent": "Permament", + "initialisierung": "Initialization", + "antilegionellenfunktion": "Anti-legionella Function", + "fernschalter_ein": "Remote control enabled", + "1_x_warmwasser": "1 x DHW", + "bereit_keine_ladung": "Ready, not loading", + "solarbetrieb": "Solar mode", + "reduzierter_betrieb": "Limited mode", + "smart_home": "SmartHome", + "smart_grid": "SmartGrid", + "ruhekontakt": "Rest contact", + "vorspulen": "Entry rinsing", + "zunden": "Ignition", + "stabilisierung": "Stablization", + "ventilprufung": "Valve test", + "nachspulen": "Post-flush", + "softstart": "Soft start", + "taktsperre": "Anti-cycle", + "betrieb_ohne_brenner": "Working without burner", + "abgasklappe": "Flue gas damper", + "storung": "Fault", + "gradienten_uberwachung": "Gradient monitoring", + "gasdruck": "Gas pressure", + "spreizung_hoch": "dT too wide", + "spreizung_kf": "Spread KF", + "test": "Test", + "start": "Start", + "frost_heizkreis": "Heating circuit frost", + "frost_warmwasser": "DHW frost", + "schornsteinfeger": "Emissions test", + "kombibetrieb": "Combi mode", + "parallelbetrieb": "Parallel mode", + "warmwasserbetrieb": "DHW mode", + "warmwassernachlauf": "DHW run-on", + "heizbetrieb": "Heating mode", + "nachlauf_heizkreispumpe": "Heating circuit pump run-on", + "frostschutz": "Frost protection", + "kaskadenbetrieb": "Cascade operation", + "glt_betrieb": "BMS mode", + "kalibration": "Calibration", + "kalibration_heizbetrieb": "Heating mode calibration", + "kalibration_warmwasserbetrieb": "DHW calibration", + "kalibration_kombibetrieb": "Combi mode calibration", + "warmwasser_schnellstart": "DHW quick start", + "externe_deaktivierung": "External deactivation", + "heizung": "Heating", + "warmwasser": "DHW", + "kombigerat": "Combi boiler", + "kombigerat_mit_solareinbindung": "Combi boiler with solar integration", + "heizgerat_mit_speicher": "Boiler with cylinder", + "nur_heizgerat": "Boiler only", + "aktiviert": "Activated", + "sparen": "Economy", + "estrichtrocknung": "Screed drying", + "telefonfernschalter": "Telephone remote switch", + "partymodus": "Party mode", + "urlaubsmodus": "Holiday mode", + "automatik_ein": "Automatic ON", + "automatik_aus": "Automatic OFF", + "permanentbetrieb": "Permanent mode", + "sparbetrieb": "Economy mode", + "auto_on_cool": "AutoOnCool", + "auto_off_cool": "AutoOffCool", + "perm_cooling": "PermCooling", + "absenkbetrieb": "Setback mode", + "eco": "Eco", + "absenkstop": "Setback stop", + "at_abschaltung": "OT shutdown", + "rt_abschaltung": "RT shutdown", + "at_frostschutz": "OT frost protection", + "rt_frostschutz": "RT frost protection", + "dhw_prior": "DHWPrior", + "cooling": "Cooling", + "tpw": "TPW", + "warmwasservorrang": "DHW priority", + "mindest_kombizeit": "Minimum combi time" + } + } +} diff --git a/homeassistant/components/wolflink/translations/ca.json b/homeassistant/components/wolflink/translations/ca.json new file mode 100644 index 00000000000..80f8a793309 --- /dev/null +++ b/homeassistant/components/wolflink/translations/ca.json @@ -0,0 +1,27 @@ +{ + "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": { + "device": { + "data": { + "device_name": "Dispositiu" + }, + "title": "Selecci\u00f3 de dispositiu WOLF" + }, + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "title": "Connexi\u00f3 WOLF SmartSet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/en.json b/homeassistant/components/wolflink/translations/en.json new file mode 100644 index 00000000000..18148bea38a --- /dev/null +++ b/homeassistant/components/wolflink/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "device": { + "data": { + "device_name": "Device" + }, + "title": "Select WOLF device" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "WOLF SmartSet connection" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/es.json b/homeassistant/components/wolflink/translations/es.json new file mode 100644 index 00000000000..359a2d0b27e --- /dev/null +++ b/homeassistant/components/wolflink/translations/es.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "device": { + "data": { + "device_name": "Dispositivo" + }, + "title": "Seleccionar dispositivo WOLF" + }, + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "title": "Conexi\u00f3n WOLF SmartSet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/it.json b/homeassistant/components/wolflink/translations/it.json new file mode 100644 index 00000000000..6e88a6b3e29 --- /dev/null +++ b/homeassistant/components/wolflink/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "device": { + "data": { + "device_name": "Dispositivo" + }, + "title": "Selezionare il dispositivo WOLF" + }, + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Connessione WOLF SmartSet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/ko.json b/homeassistant/components/wolflink/translations/ko.json new file mode 100644 index 00000000000..614604a56c1 --- /dev/null +++ b/homeassistant/components/wolflink/translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "device": { + "data": { + "device_name": "\uae30\uae30" + }, + "title": "WOLF \uae30\uae30 \uc120\ud0dd\ud558\uae30" + }, + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "title": "WOLF SmartSet \uc5f0\uacb0" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/lb.json b/homeassistant/components/wolflink/translations/lb.json new file mode 100644 index 00000000000..97a65b12d02 --- /dev/null +++ b/homeassistant/components/wolflink/translations/lb.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "device": { + "data": { + "device_name": "Apparat" + }, + "title": "WOLF Apparat auswielen" + }, + "user": { + "data": { + "password": "Passwuert", + "username": "Benotzernumm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/no.json b/homeassistant/components/wolflink/translations/no.json new file mode 100644 index 00000000000..a158cba44a2 --- /dev/null +++ b/homeassistant/components/wolflink/translations/no.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "device": { + "data": { + "device_name": "Enhet" + }, + "title": "Velg WOLF-enhet" + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "title": "WOLF SmartSet-tilkobling" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/pl.json b/homeassistant/components/wolflink/translations/pl.json new file mode 100644 index 00000000000..483c73aac3d --- /dev/null +++ b/homeassistant/components/wolflink/translations/pl.json @@ -0,0 +1,27 @@ +{ + "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": "[%key::common::config_flow::error::unknown%]" + }, + "step": { + "device": { + "data": { + "device_name": "Urz\u0105dzenie" + }, + "title": "Wybierz urz\u0105dzenie WOLF" + }, + "user": { + "data": { + "password": "[%key_id:common::config_flow::data::password%]", + "username": "[%key_id:common::config_flow::data::username%]" + }, + "title": "Po\u0142\u0105czenie WOLF SmartSet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/ru.json b/homeassistant/components/wolflink/translations/ru.json new file mode 100644 index 00000000000..a15fb94c8ff --- /dev/null +++ b/homeassistant/components/wolflink/translations/ru.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "device": { + "data": { + "device_name": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e WOLF" + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "title": "WOLF SmartSet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.bg.json b/homeassistant/components/wolflink/translations/sensor.bg.json new file mode 100644 index 00000000000..d9a9400e4d5 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.bg.json @@ -0,0 +1,14 @@ +{ + "state": { + "wolflink__state": { + "test": "\u0422\u0435\u0441\u0442", + "tpw": "TPW", + "urlaubsmodus": "\u0412\u0430\u043a\u0430\u043d\u0446\u0438\u043e\u043d\u0435\u043d \u0440\u0435\u0436\u0438\u043c", + "ventilprufung": "\u0422\u0435\u0441\u0442 \u043d\u0430 \u043a\u043b\u0430\u043f\u0430\u043d\u0430", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW \u0431\u044a\u0440\u0437 \u0441\u0442\u0430\u0440\u0442", + "warmwasserbetrieb": "\u0420\u0435\u0436\u0438\u043c \u043d\u0430 DHW", + "warmwasservorrang": "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043d\u0430 DHW" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.ca.json b/homeassistant/components/wolflink/translations/sensor.ca.json new file mode 100644 index 00000000000..a671f608879 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.ca.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x ACS", + "abgasklappe": "Amortidor de gasos", + "absenkbetrieb": "Mode de retroc\u00e9s", + "absenkstop": "Retroc\u00e9s aturat", + "aktiviert": "Activat", + "antilegionellenfunktion": "Funci\u00f3 anti-legionel\u00b7la", + "at_abschaltung": "Aturada OT", + "at_frostschutz": "Protecci\u00f3 contra gelades OT", + "aus": "Desactivat", + "auto": "Autom\u00e0tic", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Apagada autom\u00e0tica", + "automatik_ein": "Engegada autom\u00e0tica", + "bereit_keine_ladung": "Llest, no carregant-se", + "betrieb_ohne_brenner": "Funcionant sense cremador", + "cooling": "Refredant", + "deaktiviert": "Inactiu", + "dhw_prior": "DHWPrior", + "eco": "Eco", + "ein": "Habilitat", + "estrichtrocknung": "Assecant superf\u00edcie", + "externe_deaktivierung": "Desactivaci\u00f3 externa", + "fernschalter_ein": "Control remot activat", + "frost_heizkreis": "Congelaci\u00f3 circuit calentador", + "frost_warmwasser": "Congelaci\u00f3 ACS", + "frostschutz": "Protecci\u00f3 contra gelades", + "gasdruck": "Pressi\u00f3 del gas", + "glt_betrieb": "Mode BMS", + "gradienten_uberwachung": "Monitoritzaci\u00f3 de gradient", + "heizbetrieb": "Mode de calefacci\u00f3", + "heizgerat_mit_speicher": "Caldera amb cilindre", + "heizung": "Escalfant", + "initialisierung": "Inicialitzaci\u00f3", + "kalibration": "Calibraci\u00f3", + "kalibration_heizbetrieb": "Calibraci\u00f3 mode de calefacci\u00f3", + "kalibration_kombibetrieb": "Calibraci\u00f3 mode de combi", + "kalibration_warmwasserbetrieb": "Calibraci\u00f3 ACS", + "kaskadenbetrieb": "Funcionament en cascada", + "kombibetrieb": "Mode combi", + "kombigerat": "Caldera combi", + "kombigerat_mit_solareinbindung": "Caldera combi amb integraci\u00f3 solar", + "mindest_kombizeit": "Temps combi m\u00ednim", + "nachlauf_heizkreispumpe": "Bomba del circuit calentador en marxa", + "nachspulen": "Post-desc\u00e0rrega", + "nur_heizgerat": "Nom\u00e9s caldera", + "parallelbetrieb": "Mode paral\u00b7lel", + "partymodus": "Mode festa", + "perm_cooling": "PermCooling", + "permanent": "Permanent", + "permanentbetrieb": "Mode permanent", + "reduzierter_betrieb": "Mode limitat", + "rt_abschaltung": "Aturada RT", + "rt_frostschutz": "Protecci\u00f3 contra gelades RT", + "ruhekontakt": "Contacte de rep\u00f2s", + "schornsteinfeger": "Prova d'emissions", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Inici suau", + "solarbetrieb": "Mode solar", + "sparbetrieb": "Mode econ\u00f2mic", + "sparen": "Economia", + "spreizung_hoch": "dT massa ample", + "spreizung_kf": "Escampant KF", + "stabilisierung": "Estabilitzaci\u00f3", + "standby": "En espera", + "start": "Inici", + "storung": "Error", + "taktsperre": "Anti-cicle", + "telefonfernschalter": "Interruptor remot telef\u00f2nic", + "test": "Prova", + "tpw": "TPW", + "urlaubsmodus": "Mode de vacances", + "ventilprufung": "Test de v\u00e0lvula", + "vorspulen": "Entrada esbandit", + "warmwasser": "ACS", + "warmwasser_schnellstart": "Inici r\u00e0pid d'ACS", + "warmwasserbetrieb": "Mode ACS", + "warmwassernachlauf": "ACS en marxa", + "warmwasservorrang": "Prioritat ACS", + "zunden": "Ignici\u00f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.en.json b/homeassistant/components/wolflink/translations/sensor.en.json new file mode 100644 index 00000000000..ea60e233907 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.en.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "Flue gas damper", + "absenkbetrieb": "Setback mode", + "absenkstop": "Setback stop", + "aktiviert": "Activated", + "antilegionellenfunktion": "Anti-legionella Function", + "at_abschaltung": "OT shutdown", + "at_frostschutz": "OT frost protection", + "aus": "Disabled", + "auto": "Auto", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Automatic OFF", + "automatik_ein": "Automatic ON", + "bereit_keine_ladung": "Ready, not loading", + "betrieb_ohne_brenner": "Working without burner", + "cooling": "Cooling", + "deaktiviert": "Inactive", + "dhw_prior": "DHWPrior", + "eco": "Eco", + "ein": "Enabled", + "estrichtrocknung": "Screed drying", + "externe_deaktivierung": "External deactivation", + "fernschalter_ein": "Remote control enabled", + "frost_heizkreis": "Heating circuit frost", + "frost_warmwasser": "DHW frost", + "frostschutz": "Frost protection", + "gasdruck": "Gas pressure", + "glt_betrieb": "BMS mode", + "gradienten_uberwachung": "Gradient monitoring", + "heizbetrieb": "Heating mode", + "heizgerat_mit_speicher": "Boiler with cylinder", + "heizung": "Heating", + "initialisierung": "Initialization", + "kalibration": "Calibration", + "kalibration_heizbetrieb": "Heating mode calibration", + "kalibration_kombibetrieb": "Combi mode calibration", + "kalibration_warmwasserbetrieb": "DHW calibration", + "kaskadenbetrieb": "Cascade operation", + "kombibetrieb": "Combi mode", + "kombigerat": "Combi boiler", + "kombigerat_mit_solareinbindung": "Combi boiler with solar integration", + "mindest_kombizeit": "Minimum combi time", + "nachlauf_heizkreispumpe": "Heating circuit pump run-on", + "nachspulen": "Post-flush", + "nur_heizgerat": "Boiler only", + "parallelbetrieb": "Parallel mode", + "partymodus": "Party mode", + "perm_cooling": "PermCooling", + "permanent": "Permament", + "permanentbetrieb": "Permanent mode", + "reduzierter_betrieb": "Limited mode", + "rt_abschaltung": "RT shutdown", + "rt_frostschutz": "RT frost protection", + "ruhekontakt": "Rest contact", + "schornsteinfeger": "Emissions test", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Soft start", + "solarbetrieb": "Solar mode", + "sparbetrieb": "Economy mode", + "sparen": "Economy", + "spreizung_hoch": "dT too wide", + "spreizung_kf": "Spread KF", + "stabilisierung": "Stablization", + "standby": "Standby", + "start": "Start", + "storung": "Fault", + "taktsperre": "Anti-cycle", + "telefonfernschalter": "Telephone remote switch", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Holiday mode", + "ventilprufung": "Valve test", + "vorspulen": "Entry rinsing", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW quick start", + "warmwasserbetrieb": "DHW mode", + "warmwassernachlauf": "DHW run-on", + "warmwasservorrang": "DHW priority", + "zunden": "Ignition" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.es.json b/homeassistant/components/wolflink/translations/sensor.es.json new file mode 100644 index 00000000000..98c6b5bd242 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.es.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "Compuerta de gases de combusti\u00f3n", + "absenkbetrieb": "Modo de retroceso", + "absenkstop": "Parada de retroceso", + "aktiviert": "Activado", + "antilegionellenfunktion": "Funci\u00f3n anti legionela", + "at_abschaltung": "Apagado de OT", + "at_frostschutz": "OT protecci\u00f3n contra heladas", + "aus": "Deshabilitado", + "auto": "Autom\u00e1tico", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Apagado autom\u00e1tico", + "automatik_ein": "Encendido autom\u00e1tico", + "bereit_keine_ladung": "Listo, no est\u00e1 cargando", + "betrieb_ohne_brenner": "Trabajando sin quemador", + "cooling": "Enfriamiento", + "deaktiviert": "Inactivo", + "dhw_prior": "DHWPrior", + "eco": "Eco", + "ein": "Habilitado", + "estrichtrocknung": "Secado en regla", + "externe_deaktivierung": "Desactivaci\u00f3n externa", + "fernschalter_ein": "Mando a distancia activado", + "frost_heizkreis": "Escarcha del circuito de calefacci\u00f3n", + "frost_warmwasser": "Heladas de DHW", + "frostschutz": "Protecci\u00f3n contra las heladas", + "gasdruck": "Presion del gas", + "glt_betrieb": "Modo BMS", + "gradienten_uberwachung": "Monitoreo de gradiente", + "heizbetrieb": "Modo de calefacci\u00f3n", + "heizgerat_mit_speicher": "Caldera con cilindro", + "heizung": "Calefacci\u00f3n", + "initialisierung": "Inicializaci\u00f3n", + "kalibration": "Calibraci\u00f3n", + "kalibration_heizbetrieb": "Calibraci\u00f3n del modo de calefacci\u00f3n", + "kalibration_kombibetrieb": "Calibraci\u00f3n en modo combinado", + "kalibration_warmwasserbetrieb": "Calibraci\u00f3n DHW", + "kaskadenbetrieb": "Operaci\u00f3n en cascada", + "kombibetrieb": "Modo combinado", + "kombigerat": "Caldera combinada", + "kombigerat_mit_solareinbindung": "Caldera Combi con integraci\u00f3n solar", + "mindest_kombizeit": "Tiempo m\u00ednimo de combinaci\u00f3n", + "nachlauf_heizkreispumpe": "Bomba de circuito de calefacci\u00f3n en ejecuci\u00f3n", + "nachspulen": "Enviar enjuague", + "nur_heizgerat": "S\u00f3lo la caldera", + "parallelbetrieb": "Modo paralelo", + "partymodus": "Modo fiesta", + "perm_cooling": "Enfriamiento permanente", + "permanent": "Permanente", + "permanentbetrieb": "Modo permanente", + "reduzierter_betrieb": "Modo limitado", + "rt_abschaltung": "RT apagado", + "rt_frostschutz": "RT protecci\u00f3n contra heladas", + "ruhekontakt": "Contacto de reposo", + "schornsteinfeger": "Prueba de emisiones", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Arranque suave.", + "solarbetrieb": "Modo solar", + "sparbetrieb": "Modo econ\u00f3mico", + "sparen": "Econom\u00eda", + "spreizung_hoch": "dT demasiado ancho", + "spreizung_kf": "Difundir el KF", + "stabilisierung": "Estabilizaci\u00f3n", + "standby": "En espera", + "start": "Arranque", + "storung": "Fallo", + "taktsperre": "Anti-ciclo", + "telefonfernschalter": "Interruptor remoto del tel\u00e9fono", + "test": "Prueba", + "tpw": "TPW", + "urlaubsmodus": "Modo vacaciones", + "ventilprufung": "Prueba de la v\u00e1lvula", + "vorspulen": "Enjuague de entrada", + "warmwasser": "DHW", + "warmwasser_schnellstart": "Inicio r\u00e1pido de DHW", + "warmwasserbetrieb": "Modo DHW", + "warmwassernachlauf": "DHW en ejecuci\u00f3n", + "warmwasservorrang": "Prioridad de DHW", + "zunden": "Encendido" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.it.json b/homeassistant/components/wolflink/translations/sensor.it.json new file mode 100644 index 00000000000..e5eb50e6586 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.it.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x ACS", + "abgasklappe": "Serranda fumi", + "absenkbetrieb": "Modalit\u00e0 contrattempo", + "absenkstop": "Stop al contrattempo", + "aktiviert": "Attivato", + "antilegionellenfunktion": "Funzione anti-legionella", + "at_abschaltung": "Spegnimento OT", + "at_frostschutz": "Protezione antigelo OT", + "aus": "Disabilitato", + "auto": "Automatico", + "auto_off_cool": "Autospegnimento Raffreddamento", + "auto_on_cool": "Autoaccensione Raffreddamento", + "automatik_aus": "Spegnimento automatico", + "automatik_ein": "Accensione automatica", + "bereit_keine_ladung": "Pronto, non in caricamento", + "betrieb_ohne_brenner": "Funzionamento senza bruciatore", + "cooling": "Raffreddamento", + "deaktiviert": "Inattivo", + "dhw_prior": "Priorit\u00e0 ACS", + "eco": "Eco", + "ein": "Abilitato", + "estrichtrocknung": "Asciugatura pavimento", + "externe_deaktivierung": "Disattivazione esterna", + "fernschalter_ein": "Telecomando abilitato", + "frost_heizkreis": "Circuito di riscaldamento antigelo", + "frost_warmwasser": "Gelo ACS", + "frostschutz": "Protezione antigelo", + "gasdruck": "Pressione del gas", + "glt_betrieb": "Modalit\u00e0 BMS", + "gradienten_uberwachung": "Monitoraggio del gradiente", + "heizbetrieb": "Modalit\u00e0 di riscaldamento", + "heizgerat_mit_speicher": "Scaldabagno", + "heizung": "Riscaldamento", + "initialisierung": "Inizializzazione", + "kalibration": "Calibrazione", + "kalibration_heizbetrieb": "Calibrazione della modalit\u00e0 di riscaldamento", + "kalibration_kombibetrieb": "Calibrazione della modalit\u00e0 combinata", + "kalibration_warmwasserbetrieb": "Calibrazione ACS", + "kaskadenbetrieb": "Funzionamento a cascata", + "kombibetrieb": "Modalit\u00e0 combinata", + "kombigerat": "Caldaia combinata", + "kombigerat_mit_solareinbindung": "Caldaia combinata con integrazione solare", + "mindest_kombizeit": "Tempo minimo combinato", + "nachlauf_heizkreispumpe": "Pompa del circuito di riscaldamento accesa", + "nachspulen": "Postlavaggio", + "nur_heizgerat": "Solo caldaia", + "parallelbetrieb": "Modalit\u00e0 parallela", + "partymodus": "Modalit\u00e0 festa", + "perm_cooling": "Raffreddamento Permanente", + "permanent": "Permanente", + "permanentbetrieb": "Modalit\u00e0 permanente", + "reduzierter_betrieb": "Modalit\u00e0 limitata", + "rt_abschaltung": "Spegnimento RT", + "rt_frostschutz": "Protezione antigelo RT", + "ruhekontakt": "Contatto di riposo", + "schornsteinfeger": "Test delle emissioni", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Avvio graduale", + "solarbetrieb": "Modalit\u00e0 Solare", + "sparbetrieb": "Modalit\u00e0 Economica", + "sparen": "Economia", + "spreizung_hoch": "dT troppo grande", + "spreizung_kf": "Diffusione KF", + "stabilisierung": "Stabilizzazione", + "standby": "In attesa", + "start": "Inizio", + "storung": "Guasto", + "taktsperre": "Blocco del ciclo", + "telefonfernschalter": "Interruttore a telecomando telefonico", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Modalit\u00e0 Vacanza", + "ventilprufung": "Test della valvola", + "vorspulen": "Prelavaggio", + "warmwasser": "ACS", + "warmwasser_schnellstart": "Avvio rapido ACS", + "warmwasserbetrieb": "Modalit\u00e0 ACS", + "warmwassernachlauf": "ACS in funzione", + "warmwasservorrang": "Priorit\u00e0 ACS", + "zunden": "Accensione" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.ko.json b/homeassistant/components/wolflink/translations/sensor.ko.json new file mode 100644 index 00000000000..99e965e1b21 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.ko.json @@ -0,0 +1,12 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "\uc5f0\ud1b5 \uac00\uc2a4 \uc870\uc808\uae30", + "absenkbetrieb": "\uc911\ub2e8 \ub300\uccb4 \ubaa8\ub4dc", + "absenkstop": "\uc911\ub2e8 \ub300\uccb4 \uc911\uc9c0", + "aktiviert": "\ud65c\uc131\ud654", + "antilegionellenfunktion": "\ud56d \ub808\uc9c0\uc624\ub12c\ub77c\uade0 \uae30\ub2a5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.lb.json b/homeassistant/components/wolflink/translations/sensor.lb.json new file mode 100644 index 00000000000..94c46e4f362 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.lb.json @@ -0,0 +1,56 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1x DHW", + "aktiviert": "Aktiv\u00e9iert", + "at_abschaltung": "OT ausmaachen", + "at_frostschutz": "OT Frostschutz", + "aus": "Deaktiv\u00e9iert", + "auto": "Auto", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Automatik AUS", + "automatik_ein": "Automatik UN", + "bereit_keine_ladung": "Prett, lued net", + "cooling": "Ofkillen", + "deaktiviert": "Inaktiv", + "eco": "Eco", + "ein": "Aktiv\u00e9iert", + "frostschutz": "Frostschutz", + "gasdruck": "Gas Drock", + "glt_betrieb": "BMS Modus", + "heizbetrieb": "Heizung Modus", + "heizung": "Heizung", + "initialisierung": "Initialis\u00e9ierung", + "kalibration": "Kalibratioun", + "kalibration_warmwasserbetrieb": "DHW Kalibratioun", + "parallelbetrieb": "Parrallel Modus", + "partymodus": "Party Modus", + "permanent": "Permanent", + "permanentbetrieb": "Permanente Modus", + "reduzierter_betrieb": "Limit\u00e9ierte Modus", + "rt_abschaltung": "RT ausmaachen", + "rt_frostschutz": "RT Frostschutz", + "schornsteinfeger": "Emissioun Test", + "smart_home": "SmartHome", + "softstart": "Soft Start", + "solarbetrieb": "Solar Modus", + "sparbetrieb": "Economy Modus", + "sparen": "Economy", + "spreizung_hoch": "dT ze breet", + "spreizung_kf": "KF ausbreeden", + "stabilisierung": "Stabilis\u00e9ierung", + "standby": "Standby", + "start": "Start", + "storung": "Feeler", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Vakanze Modus", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW Schnell Start", + "warmwasserbetrieb": "DHW Modus", + "warmwasservorrang": "DHW Priorit\u00e9it", + "zunden": "Z\u00fcndung" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.no.json b/homeassistant/components/wolflink/translations/sensor.no.json new file mode 100644 index 00000000000..fcd93f0b01b --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.no.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "R\u00f8ykgassspjeld", + "absenkbetrieb": "Tilbakeslag-modus", + "absenkstop": "Tilbakeslag stopp", + "aktiviert": "Aktivert", + "antilegionellenfunktion": "Anti-legionella-funksjon", + "at_abschaltung": "OT-avstengning", + "at_frostschutz": "OT frostbeskyttelse", + "aus": "Deaktivert", + "auto": "Auto", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Automatisk AV", + "automatik_ein": "Automatisk P\u00c5", + "bereit_keine_ladung": "Klar, laster ikke", + "betrieb_ohne_brenner": "Arbeide uten brenner", + "cooling": "Kj\u00f8ling", + "deaktiviert": "inaktiv", + "dhw_prior": "DHWPrior", + "eco": "\u00d8ko", + "ein": "Aktivert", + "estrichtrocknung": "Screed t\u00f8rking", + "externe_deaktivierung": "Ekstern deaktivering", + "fernschalter_ein": "Fjernkontroll aktivert", + "frost_heizkreis": "Frost for varmekrets", + "frost_warmwasser": "DHW frost", + "frostschutz": "Frostbeskyttelse", + "gasdruck": "Gasstrykk", + "glt_betrieb": "BMS-modus", + "gradienten_uberwachung": "Gradient overv\u00e5king", + "heizbetrieb": "Oppvarmingsmodus", + "heizgerat_mit_speicher": "Kjele med sylinder", + "heizung": "Oppvarming", + "initialisierung": "Initialisering", + "kalibration": "Kalibrering", + "kalibration_heizbetrieb": "Kalibrering av varmemodus", + "kalibration_kombibetrieb": "Kalibrering av kombimodus", + "kalibration_warmwasserbetrieb": "DHW-kalibrering", + "kaskadenbetrieb": "Kaskadedrift", + "kombibetrieb": "Kombimodus", + "kombigerat": "Kombikjel", + "kombigerat_mit_solareinbindung": "Kombikjele med solintegrasjon", + "mindest_kombizeit": "Minimum kombinasjonstid", + "nachlauf_heizkreispumpe": "Varmekrets pumpen kj\u00f8res p\u00e5", + "nachspulen": "Post-flush", + "nur_heizgerat": "Bare kjele", + "parallelbetrieb": "Parallell modus", + "partymodus": "Festmodus", + "perm_cooling": "PermKj\u00f8ling", + "permanent": "permament", + "permanentbetrieb": "Permanent modus", + "reduzierter_betrieb": "Begrenset modus", + "rt_abschaltung": "RT-avstengning", + "rt_frostschutz": "RT frostsikring", + "ruhekontakt": "Hvilekontakt", + "schornsteinfeger": "Utslippstest", + "smart_grid": "Smartgrid", + "smart_home": "SmartHome", + "softstart": "Myk start", + "solarbetrieb": "Solmodus", + "sparbetrieb": "\u00d8konomimodus", + "sparen": "\u00d8konomi", + "spreizung_hoch": "dT for bred", + "spreizung_kf": "Spre KF", + "stabilisierung": "Stablisering", + "standby": "Avventer", + "start": "Start", + "storung": "Feil", + "taktsperre": "Anti-syklus", + "telefonfernschalter": "Fjernkontroll for telefon", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Feriemodus", + "ventilprufung": "Ventiltest", + "vorspulen": "Inngangsskylling", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW hurtigstart", + "warmwasserbetrieb": "DHW-modus", + "warmwassernachlauf": "DHW run-on", + "warmwasservorrang": "DHW-prioritet", + "zunden": "Tenning" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.pl.json b/homeassistant/components/wolflink/translations/sensor.pl.json new file mode 100644 index 00000000000..ba65f3ab992 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.pl.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x CWU", + "abgasklappe": "Przepustnica spalin", + "absenkbetrieb": "Wych\u0142adzanie", + "absenkstop": "Zatrzymanie wych\u0142adzania", + "aktiviert": "W\u0142\u0105czone", + "antilegionellenfunktion": "Funkcja antylegionella", + "at_abschaltung": "Granica wy\u0142\u0105czenia temperatura zewn\u0119trzna", + "at_frostschutz": "AT Ochrona antyzamro\u017ceniowa", + "aus": "Wy\u0142\u0105czone", + "auto": "Automatyczny", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Automatyczne wy\u0142\u0105czanie", + "automatik_ein": "Automatyczne w\u0142\u0105czenie", + "bereit_keine_ladung": "Gotowe, nie \u0142aduj\u0119", + "betrieb_ohne_brenner": "Praca bez palnika", + "cooling": "Ch\u0142odzenie", + "deaktiviert": "Nieaktywne", + "dhw_prior": "Priorytet CWU", + "eco": "Eco", + "ein": "W\u0142\u0105czony", + "estrichtrocknung": "Suszenie jastrychu", + "externe_deaktivierung": "Zewn\u0119trzna dezaktywacja", + "fernschalter_ein": "Zdalne sterowanie w\u0142\u0105czone", + "frost_heizkreis": "Mr\u00f3z w obwodzie grzewczym", + "frost_warmwasser": "Mr\u00f3z na CWU", + "frostschutz": "Zabezpieczenie przed zamarzaniem", + "gasdruck": "Ci\u015bnienie gazu", + "glt_betrieb": "Tryb BMS", + "gradienten_uberwachung": "Monitorowanie gradientu", + "heizbetrieb": "Tryb ogrzewania", + "heizgerat_mit_speicher": "Urz\u0105dzenie grzewcze z zasobnikiem", + "heizung": "Ogrzewanie", + "initialisierung": "Inicjalizacja", + "kalibration": "Kalibracja", + "kalibration_heizbetrieb": "Kalibracja trybu ogrzewania", + "kalibration_kombibetrieb": "Kalibracja trybu kombi", + "kalibration_warmwasserbetrieb": "Kalibracja CWU", + "kaskadenbetrieb": "Tryb kaskadowy", + "kombibetrieb": "Tryb kombi", + "kombigerat": "Urz\u0105dzenie kombi", + "kombigerat_mit_solareinbindung": "Urz\u0105dzenie kombi zintegrowane z solarem", + "mindest_kombizeit": "Minimalny czas kombi", + "nachlauf_heizkreispumpe": "Wybieg pompy obiegu grzewczego", + "nachspulen": "Przedmuchiwanie", + "nur_heizgerat": "Tylko urz\u0105dzenie grzewcze", + "parallelbetrieb": "Tryb r\u00f3wnoleg\u0142y", + "partymodus": "Tryb imprezowy", + "perm_cooling": "PermCooling", + "permanent": "Sta\u0142y", + "permanentbetrieb": "Tryb pracy ci\u0105g\u0142ej", + "reduzierter_betrieb": "Tryb ograniczony", + "rt_abschaltung": "Wy\u0142\u0105czenie RT", + "rt_frostschutz": "RT Ochrona antyzamro\u017ceniowa", + "ruhekontakt": "Zestyk spoczynkowy", + "schornsteinfeger": "Kominiarz", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "\u0141agodny rozruch", + "solarbetrieb": "Tryb solarny", + "sparbetrieb": "Tryb oszcz\u0119dzania", + "sparen": "Oszcz\u0119dzanie", + "spreizung_hoch": "Zbyt du\u017cy zakres", + "spreizung_kf": "Zakres KF", + "stabilisierung": "Stabilizacja", + "standby": "Tryb czuwania", + "start": "Start", + "storung": "Usterka", + "taktsperre": "Blokada taktu", + "telefonfernschalter": "Zdalne sterowanie za pomoc\u0105 telefonu", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Tryb wakacyjny", + "ventilprufung": "Kontrola zawor\u00f3w", + "vorspulen": "P\u0142ukanie wst\u0119pne", + "warmwasser": "CWU", + "warmwasser_schnellstart": "Szybki start CWU", + "warmwasserbetrieb": "Tryb CWU", + "warmwassernachlauf": "Wybieg CWU", + "warmwasservorrang": "Priorytet CWU", + "zunden": "Zap\u0142on" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.ru.json b/homeassistant/components/wolflink/translations/sensor.ru.json new file mode 100644 index 00000000000..cace84a9da3 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.ru.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 \u0445 \u0413\u0412\u0421", + "abgasklappe": "\u0417\u0430\u0441\u043b\u043e\u043d\u043a\u0430 \u0434\u044b\u043c\u043e\u0432\u044b\u0445 \u0433\u0430\u0437\u043e\u0432", + "absenkbetrieb": "\u0420\u0435\u0436\u0438\u043c \u0430\u0432\u0430\u0440\u0438\u0438", + "absenkstop": "\u0410\u0432\u0430\u0440\u0438\u0439\u043d\u0430\u044f \u043e\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430", + "aktiviert": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d\u043e", + "antilegionellenfunktion": "\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0430\u043d\u0442\u0438-\u043b\u0435\u0433\u0438\u043e\u043d\u0435\u043b\u043b\u044b", + "at_abschaltung": "\u041e\u0422 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "at_frostschutz": "\u041e\u0422 \u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 \u0437\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u044f", + "aus": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "auto": "\u0410\u0432\u0442\u043e", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "automatik_ein": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "bereit_keine_ladung": "\u0413\u043e\u0442\u043e\u0432, \u043d\u0435 \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442\u0441\u044f", + "betrieb_ohne_brenner": "\u0420\u0430\u0431\u043e\u0442\u0430 \u0431\u0435\u0437 \u0433\u043e\u0440\u0435\u043b\u043a\u0438", + "cooling": "\u041e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "deaktiviert": "\u041d\u0435\u0430\u043a\u0442\u0438\u0432\u043d\u043e", + "dhw_prior": "DHWPrior", + "eco": "\u042d\u043a\u043e", + "ein": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "estrichtrocknung": "\u0421\u0443\u0448\u043a\u0430", + "externe_deaktivierung": "\u0412\u043d\u0435\u0448\u043d\u044f\u044f \u0434\u0435\u0430\u043a\u0442\u0438\u0432\u0430\u0446\u0438\u044f", + "fernschalter_ein": "\u0414\u0438\u0441\u0442\u0430\u043d\u0446\u0438\u043e\u043d\u043d\u043e\u0435 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "frost_heizkreis": "\u0417\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u0435 \u043a\u043e\u043d\u0442\u0443\u0440\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f", + "frost_warmwasser": "\u0417\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u0435 \u0413\u0412\u0421", + "frostschutz": "\u0417\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 \u0437\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u044f", + "gasdruck": "\u0414\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0433\u0430\u0437\u0430", + "glt_betrieb": "\u0420\u0435\u0436\u0438\u043c BMS", + "gradienten_uberwachung": "\u0413\u0440\u0430\u0434\u0438\u0435\u043d\u0442\u043d\u044b\u0439 \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433", + "heizbetrieb": "\u0420\u0435\u0436\u0438\u043c \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f", + "heizgerat_mit_speicher": "\u041a\u043e\u0442\u0435\u043b \u0441 \u0446\u0438\u043b\u0438\u043d\u0434\u0440\u043e\u043c", + "heizung": "\u041e\u0431\u043e\u0433\u0440\u0435\u0432", + "initialisierung": "\u0418\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f", + "kalibration": "\u041a\u0430\u043b\u0438\u0431\u0440\u043e\u0432\u043a\u0430", + "kalibration_heizbetrieb": "\u041a\u0430\u043b\u0438\u0431\u0440\u043e\u0432\u043a\u0430 \u0440\u0435\u0436\u0438\u043c\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f", + "kalibration_kombibetrieb": "\u041a\u0430\u043b\u0438\u0431\u0440\u043e\u0432\u043a\u0430 \u0432 \u043a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u043c \u0440\u0435\u0436\u0438\u043c\u0435", + "kalibration_warmwasserbetrieb": "\u041a\u0430\u043b\u0438\u0431\u0440\u043e\u0432\u043a\u0430 \u0413\u0412\u0421", + "kaskadenbetrieb": "\u041a\u0430\u0441\u043a\u0430\u0434\u043d\u0430\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u044f", + "kombibetrieb": "\u041a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "kombigerat": "\u0414\u0432\u0443\u0445\u043a\u043e\u043d\u0442\u0443\u0440\u043d\u044b\u0439 \u043a\u043e\u0442\u0435\u043b", + "kombigerat_mit_solareinbindung": "\u0414\u0432\u0443\u0445\u043a\u043e\u043d\u0442\u0443\u0440\u043d\u044b\u0439 \u043a\u043e\u0442\u0435\u043b \u0441 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439 \u0441\u043e\u043b\u043d\u0435\u0447\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b", + "mindest_kombizeit": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f", + "nachlauf_heizkreispumpe": "\u0420\u0430\u0431\u043e\u0442\u0430 \u043d\u0430\u0441\u043e\u0441\u0430 \u043a\u043e\u043d\u0442\u0443\u0440\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f", + "nachspulen": "\u041f\u043e\u0441\u0442-\u043f\u0440\u043e\u043c\u044b\u0432\u043a\u0430", + "nur_heizgerat": "\u0422\u043e\u043b\u044c\u043a\u043e \u0431\u043e\u0439\u043b\u0435\u0440", + "parallelbetrieb": "\u041f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "partymodus": "\u0420\u0435\u0436\u0438\u043c \u0432\u0435\u0447\u0435\u0440\u0438\u043d\u043a\u0438", + "perm_cooling": "\u041f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e\u0435 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "permanent": "\u041f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e", + "permanentbetrieb": "\u041f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "reduzierter_betrieb": "\u041e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "rt_abschaltung": "RT \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "rt_frostschutz": "RT \u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 \u0437\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u044f", + "ruhekontakt": "\u041e\u0441\u0442\u0430\u043b\u044c\u043d\u043e\u0439 \u043a\u043e\u043d\u0442\u0430\u043a\u0442", + "schornsteinfeger": "\u0422\u0435\u0441\u0442 \u043d\u0430 \u0432\u044b\u0431\u0440\u043e\u0441\u044b", + "smart_grid": "\u0423\u043c\u043d\u0430\u044f \u0441\u0435\u0442\u044c \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u0441\u043d\u0430\u0431\u0436\u0435\u043d\u0438\u044f", + "smart_home": "\u0423\u043c\u043d\u044b\u0439 \u0434\u043e\u043c", + "softstart": "\u041c\u044f\u0433\u043a\u0438\u0439 \u0437\u0430\u043f\u0443\u0441\u043a", + "solarbetrieb": "\u0421\u043e\u043b\u043d\u0435\u0447\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "sparbetrieb": "\u0420\u0435\u0436\u0438\u043c \u044d\u043a\u043e\u043d\u043e\u043c\u0438\u0438", + "sparen": "\u042d\u043a\u043e\u043d\u043e\u043c\u0438\u044f", + "spreizung_hoch": "dT \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0448\u0438\u0440\u043e\u043a\u0438\u0439", + "spreizung_kf": "\u0421\u043f\u0440\u0435\u0434 KF", + "stabilisierung": "\u0421\u0442\u0430\u0431\u0438\u043b\u0438\u0437\u0430\u0446\u0438\u044f", + "standby": "\u041e\u0436\u0438\u0434\u0430\u043d\u0438\u0435", + "start": "\u0417\u0430\u043f\u0443\u0441\u043a", + "storung": "\u041e\u0448\u0438\u0431\u043a\u0430", + "taktsperre": "\u0410\u043d\u0442\u0438-\u0446\u0438\u043a\u043b", + "telefonfernschalter": "\u0414\u0438\u0441\u0442\u0430\u043d\u0446\u0438\u043e\u043d\u043d\u043e\u0435 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0441 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", + "test": "\u0422\u0435\u0441\u0442", + "tpw": "TPW", + "urlaubsmodus": "\u0420\u0435\u0436\u0438\u043c \"\u0432\u044b\u0445\u043e\u0434\u043d\u044b\u0435\"", + "ventilprufung": "\u0422\u0435\u0441\u0442 \u043a\u043b\u0430\u043f\u0430\u043d\u0430", + "vorspulen": "\u041f\u0440\u043e\u043c\u044b\u0432\u043a\u0430 \u0432\u0445\u043e\u0434\u0430", + "warmwasser": "\u0413\u0412\u0421", + "warmwasser_schnellstart": "\u0411\u044b\u0441\u0442\u0440\u044b\u0439 \u0437\u0430\u043f\u0443\u0441\u043a \u0413\u0412\u0421", + "warmwasserbetrieb": "\u0420\u0435\u0436\u0438\u043c \u0413\u0412\u0421", + "warmwassernachlauf": "\u0417\u0430\u043f\u0443\u0441\u043a \u0413\u0412\u0421", + "warmwasservorrang": "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u0413\u0412\u0421", + "zunden": "\u0417\u0430\u0436\u0438\u0433\u0430\u043d\u0438\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.sl.json b/homeassistant/components/wolflink/translations/sensor.sl.json new file mode 100644 index 00000000000..dbb33ed5b61 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.sl.json @@ -0,0 +1,66 @@ +{ + "state": { + "wolflink__state": { + "at_frostschutz": "OT za\u0161\u010dita pred zmrzaljo", + "aus": "Onemogo\u010deno", + "auto": "Samodejno", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Samodejno izklopljeno", + "automatik_ein": "Samodejno vklopljeno", + "bereit_keine_ladung": "Pripravljeni, se ne nalo\u017ei", + "betrieb_ohne_brenner": "Delo brez gorilnika", + "cooling": "Hlajenje", + "deaktiviert": "Neaktivno", + "dhw_prior": "DHWPrior", + "eco": "Eko", + "ein": "Omogo\u010deno", + "estrichtrocknung": "Su\u0161enje estrihov", + "externe_deaktivierung": "Zunanja deaktivacija", + "fernschalter_ein": "Omogo\u010den daljinski nadzor", + "frost_heizkreis": "Zmrzal ogrevalnega tokokroga", + "frost_warmwasser": "Zmrzal sanitarne vode", + "frostschutz": "Za\u0161\u010dita pred zmrzaljo", + "gasdruck": "Tlak plina", + "glt_betrieb": "Na\u010din BMS", + "gradienten_uberwachung": "Spremljanje stopnje", + "heizbetrieb": "Na\u010din ogrevanja", + "heizgerat_mit_speicher": "Kotel s cilindrom", + "heizung": "Ogrevanje", + "initialisierung": "Inicializacija", + "kalibration": "Kalibracija", + "kalibration_heizbetrieb": "Kalibracija na\u010dina ogrevanja", + "kalibration_kombibetrieb": "Kalibracija v kombiniranem na\u010dinu", + "reduzierter_betrieb": "Omejen na\u010din", + "rt_abschaltung": "Zaustavitev RT", + "rt_frostschutz": "RT za\u0161\u010dita pred zmrzaljo", + "ruhekontakt": "Rest kontakt", + "schornsteinfeger": "Preizkus emisij", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Mehki zagon", + "solarbetrieb": "Son\u010dni na\u010din", + "sparbetrieb": "Var\u010dni na\u010din", + "sparen": "Var\u010dno", + "spreizung_hoch": "dT pre\u0161irok", + "spreizung_kf": "\u0160irjenje KF", + "stabilisierung": "Stabilizacija", + "standby": "V pripravljenosti", + "start": "Zagon", + "storung": "Napaka", + "taktsperre": "Proti-cikel", + "telefonfernschalter": "Telefonsko daljinsko stikalo", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Po\u010ditni\u0161ki na\u010din", + "ventilprufung": "Test ventila", + "vorspulen": "Vstopno izpiranje", + "warmwasser": "Sanitarna voda", + "warmwasser_schnellstart": "Hitri zagon sanitarne vode", + "warmwasserbetrieb": "Na\u010din sanitarne vode", + "warmwassernachlauf": "Iztekanje sanitarne vode", + "warmwasservorrang": "Prioriteta sanitarne vode", + "zunden": "V\u017eig" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.tr.json b/homeassistant/components/wolflink/translations/sensor.tr.json new file mode 100644 index 00000000000..91160e3569a --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.tr.json @@ -0,0 +1,10 @@ +{ + "state": { + "wolflink__state": { + "standby": "Bekleme modu", + "start": "Ba\u015flat", + "storung": "Hata", + "test": "Test" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.uk.json b/homeassistant/components/wolflink/translations/sensor.uk.json new file mode 100644 index 00000000000..665ff99992c --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.uk.json @@ -0,0 +1,15 @@ +{ + "state": { + "wolflink__state": { + "permanent": "\u041f\u043e\u0441\u0442\u0456\u0439\u043d\u043e", + "smart_home": "\u0420\u043e\u0437\u0443\u043c\u043d\u0438\u0439 \u0434\u0456\u043c", + "sparen": "\u0415\u043a\u043e\u043d\u043e\u043c\u0456\u044f", + "stabilisierung": "\u0421\u0442\u0430\u0431\u0456\u043b\u0456\u0437\u0430\u0446\u0456\u044f", + "standby": "\u041e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u043d\u044f", + "start": "\u041f\u043e\u0447\u0430\u0442\u043e\u043a", + "storung": "\u041f\u043e\u043c\u0438\u043b\u043a\u0430", + "taktsperre": "\u0410\u043d\u0442\u0438\u0446\u0438\u043a\u043b", + "test": "\u0422\u0435\u0441\u0442" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.zh-Hant.json b/homeassistant/components/wolflink/translations/sensor.zh-Hant.json new file mode 100644 index 00000000000..c2be9263bcf --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.zh-Hant.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "\u71c3\u6c23\u8abf\u7bc0\u95a5", + "absenkbetrieb": "\u5012\u9000\u6a21\u5f0f", + "absenkstop": "\u5012\u9000\u505c\u6b62", + "aktiviert": "\u5df2\u555f\u52d5", + "antilegionellenfunktion": "\u6297\u83cc\u529f\u80fd", + "at_abschaltung": "OT \u95dc\u6a5f", + "at_frostschutz": "OT \u9632\u51cd", + "aus": "\u5df2\u95dc\u9589", + "auto": "\u81ea\u52d5", + "auto_off_cool": "\u81ea\u52d5\u95dc\u9589\u51b7\u537b", + "auto_on_cool": "\u81ea\u52d5\u958b\u555f\u51b7\u537b", + "automatik_aus": "\u81ea\u52d5\u95dc\u9589", + "automatik_ein": "\u81ea\u52d5\u958b\u555f", + "bereit_keine_ladung": "\u5c31\u7dd2\uff0c\u672a\u8f09\u5165", + "betrieb_ohne_brenner": "\u7121\u71c3\u71d2\u904b\u4f5c", + "cooling": "\u51b7\u6c23", + "deaktiviert": "\u672a\u555f\u52d5", + "dhw_prior": "DHWPrior", + "eco": "Eco", + "ein": "\u5df2\u555f\u7528", + "estrichtrocknung": "\u71d9\u677f\u4e7e\u71e5", + "externe_deaktivierung": "\u5916\u90e8\u505c\u7528", + "fernschalter_ein": "\u9060\u7aef\u63a7\u5236\u5df2\u958b\u555f", + "frost_heizkreis": "Heating circuit frost", + "frost_warmwasser": "DHW \u971c", + "frostschutz": "\u9632\u51cd", + "gasdruck": "\u6c23\u58d3", + "glt_betrieb": "BMS \u6a21\u5f0f", + "gradienten_uberwachung": "\u50be\u659c\u76e3\u63a7", + "heizbetrieb": "\u6696\u6c23\u6a21\u5f0f", + "heizgerat_mit_speicher": "\u6c7d\u7f38\u934b\u7210", + "heizung": "\u6696\u6c23", + "initialisierung": "\u521d\u59cb\u5316", + "kalibration": "\u6821\u6b63", + "kalibration_heizbetrieb": "\u6696\u6c23\u6a21\u5f0f\u6821\u6b63", + "kalibration_kombibetrieb": "\u6df7\u5408\u6a21\u5f0f\u6821\u6b63", + "kalibration_warmwasserbetrieb": "DHW \u6821\u6b63", + "kaskadenbetrieb": "\u4e32\u9023\u64cd\u4f5c", + "kombibetrieb": "\u6df7\u5408\u6a21\u5f0f", + "kombigerat": "\u6df7\u5408\u934b\u7210", + "kombigerat_mit_solareinbindung": "\u6574\u5408\u592a\u967d\u80fd\u6df7\u5408\u934b\u7210", + "mindest_kombizeit": "\u6700\u4f4e\u6df7\u5408\u6642\u9593", + "nachlauf_heizkreispumpe": "\u8ff4\u8def\u5e6b\u6d66\u904b\u884c\u6696\u6c23", + "nachspulen": "\u6c96\u6d17\u5f8c", + "nur_heizgerat": "\u50c5\u934b\u7210", + "parallelbetrieb": "\u4e26\u884c\u6a21\u5f0f", + "partymodus": "\u6d3e\u5c0d\u6a21\u5f0f", + "perm_cooling": "PermCooling", + "permanent": "\u6c38\u4e45", + "permanentbetrieb": "\u6c38\u4e45\u6a21\u5f0f", + "reduzierter_betrieb": "\u9650\u5236\u6a21\u5f0f", + "rt_abschaltung": "RT \u95dc\u6a5f", + "rt_frostschutz": "RT \u9632\u51cd", + "ruhekontakt": "Rest contact", + "schornsteinfeger": "\u6392\u653e\u6e2c\u8a66", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "\u8edf\u958b\u6a5f", + "solarbetrieb": "\u592a\u967d\u80fd\u6a21\u5f0f", + "sparbetrieb": "\u7bc0\u80fd\u6a21\u5f0f", + "sparen": "\u7bc0\u80fd", + "spreizung_hoch": "DT \u904e\u5bec", + "spreizung_kf": "Spread KF", + "stabilisierung": "\u7a69\u5b9a", + "standby": "\u5f85\u547d", + "start": "\u958b\u59cb", + "storung": "\u6545\u969c", + "taktsperre": "\u53cd\u5faa\u74b0", + "telefonfernschalter": "\u96fb\u8a71\u9060\u7aef\u958b\u95dc", + "test": "\u6e2c\u8a66", + "tpw": "TPW", + "urlaubsmodus": "\u5047\u65e5\u6a21\u5f0f", + "ventilprufung": "\u95a5\u9580\u6e2c\u8a66", + "vorspulen": "Entry rinsing", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW \u5feb\u901f\u555f\u52d5", + "warmwasserbetrieb": "DHW \u6a21\u5f0f", + "warmwassernachlauf": "DHW \u904b\u884c", + "warmwasservorrang": "DHW \u512a\u5148", + "zunden": "\u9ede\u706b" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/uk.json b/homeassistant/components/wolflink/translations/uk.json new file mode 100644 index 00000000000..a7fbdfff913 --- /dev/null +++ b/homeassistant/components/wolflink/translations/uk.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", + "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" + }, + "step": { + "device": { + "data": { + "device_name": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/zh-Hant.json b/homeassistant/components/wolflink/translations/zh-Hant.json new file mode 100644 index 00000000000..13eb90b55db --- /dev/null +++ b/homeassistant/components/wolflink/translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\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": { + "device": { + "data": { + "device_name": "\u8a2d\u5099" + }, + "title": "\u9078\u64c7 WOLF \u8a2d\u5099" + }, + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "WOLF SmartSet connection" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 393a251bb7f..70d66053209 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.10.2"], + "requirements": ["holidays==0.10.3"], "codeowners": ["@fabaff"], "quality_scale": "internal" } diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index d759785f49f..c5b74e68af5 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -142,11 +142,11 @@ async def async_setup_entry( xiaomi_gateway = await hass.async_add_executor_job( XiaomiGateway, entry.data[CONF_HOST], - entry.data[CONF_PORT], entry.data[CONF_SID], entry.data[CONF_KEY], DEFAULT_DISCOVERY_RETRY, entry.data[CONF_INTERFACE], + entry.data[CONF_PORT], entry.data[CONF_PROTOCOL], ) hass.data[DOMAIN][GATEWAYS_KEY][entry.entry_id] = xiaomi_gateway diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 1145f1afa5c..0709c7e83fa 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -57,6 +57,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "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" @@ -72,6 +73,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "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" diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index b9cfe58ac4b..c42598c2665 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -3,10 +3,11 @@ import logging from socket import gaierror import voluptuous as vol -from xiaomi_gateway import XiaomiGatewayDiscovery +from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac # pylint: disable=unused-import @@ -15,6 +16,7 @@ from .const import ( CONF_KEY, CONF_PROTOCOL, CONF_SID, + DEFAULT_DISCOVERY_RETRY, DOMAIN, ZEROCONF_GATEWAY, ) @@ -28,6 +30,11 @@ DEFAULT_INTERFACE = "any" GATEWAY_CONFIG = vol.Schema( {vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): str} ) +CONFIG_HOST = { + vol.Optional(CONF_HOST): str, + vol.Optional(CONF_MAC): str, +} +GATEWAY_CONFIG_HOST = GATEWAY_CONFIG.extend(CONFIG_HOST) GATEWAY_SETTINGS = vol.Schema( { vol.Optional(CONF_KEY): vol.All(str, vol.Length(min=16, max=16)), @@ -46,44 +53,79 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize.""" self.host = None self.interface = DEFAULT_INTERFACE + self.sid = None self.gateways = None self.selected_gateway = None + @callback + def async_show_form_step_user(self, errors): + """Show the form belonging to the user step.""" + schema = GATEWAY_CONFIG + if (self.host is None and self.sid is None) or errors: + schema = GATEWAY_CONFIG_HOST + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" errors = {} - if user_input is not None: - self.interface = user_input[CONF_INTERFACE] + if user_input is None: + return self.async_show_form_step_user(errors) - # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs. - xiaomi = XiaomiGatewayDiscovery(self.hass.add_job, [], self.interface) - try: - await self.hass.async_add_executor_job(xiaomi.discover_gateways) - except gaierror: - errors[CONF_INTERFACE] = "invalid_interface" + self.interface = user_input[CONF_INTERFACE] - if not errors: - self.gateways = xiaomi.gateways + # allow optional manual setting of host and mac + if self.host is None: + self.host = user_input.get(CONF_HOST) + if self.sid is None: + mac_address = user_input.get(CONF_MAC) - # if host is already known by zeroconf discovery - if self.host is not None: - self.selected_gateway = self.gateways.get(self.host) - if self.selected_gateway is not None: - return await self.async_step_settings() + # format sid from mac_address + if mac_address is not None: + self.sid = format_mac(mac_address).replace(":", "") - errors["base"] = "not_found_error" - else: - if len(self.gateways) == 1: - self.selected_gateway = list(self.gateways.values())[0] - return await self.async_step_settings() - if len(self.gateways) > 1: - return await self.async_step_select() + # if host is already known by zeroconf discovery or manual optional settings + if self.host is not None and self.sid is not None: + # Connect to Xiaomi Aqara Gateway + self.selected_gateway = await self.hass.async_add_executor_job( + XiaomiGateway, + self.host, + self.sid, + None, + DEFAULT_DISCOVERY_RETRY, + self.interface, + MULTICAST_PORT, + None, + ) - errors["base"] = "discovery_error" + if self.selected_gateway.connection_error: + errors[CONF_HOST] = "invalid_host" + if self.selected_gateway.mac_error: + errors[CONF_MAC] = "invalid_mac" + if errors: + return self.async_show_form_step_user(errors) - return self.async_show_form( - step_id="user", data_schema=GATEWAY_CONFIG, errors=errors - ) + return await self.async_step_settings() + + # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs. + xiaomi = XiaomiGatewayDiscovery(self.hass.add_job, [], self.interface) + try: + await self.hass.async_add_executor_job(xiaomi.discover_gateways) + except gaierror: + errors[CONF_INTERFACE] = "invalid_interface" + return self.async_show_form_step_user(errors) + + self.gateways = xiaomi.gateways + + if len(self.gateways) == 1: + self.selected_gateway = list(self.gateways.values())[0] + self.sid = self.selected_gateway.sid + return await self.async_step_settings() + if len(self.gateways) > 1: + return await self.async_step_select() + + errors["base"] = "discovery_error" + return self.async_show_form_step_user(errors) async def async_step_select(self, user_input=None): """Handle multiple aqara gateways found.""" @@ -91,6 +133,7 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: ip_adress = user_input["select_ip"] self.selected_gateway = self.gateways[ip_adress] + self.sid = self.selected_gateway.sid return await self.async_step_settings() select_schema = vol.Schema( @@ -123,12 +166,17 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="not_xiaomi_aqara") - # format mac (include semicolns and make uppercase) + # format mac (include semicolns and make lowercase) mac_address = format_mac(mac_address) + # format sid from mac_address + self.sid = mac_address.replace(":", "") + unique_id = mac_address await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + self._abort_if_unique_id_configured( + {CONF_HOST: self.host, CONF_MAC: mac_address} + ) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {"name": self.host}}) @@ -144,19 +192,18 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): key = user_input.get(CONF_KEY) ip_adress = self.selected_gateway.ip_adress port = self.selected_gateway.port - sid = self.selected_gateway.sid protocol = self.selected_gateway.proto if key is not None: # validate key by issuing stop ringtone playback command. self.selected_gateway.key = key - valid_key = self.selected_gateway.write_to_hub(sid, mid=10000) + valid_key = self.selected_gateway.write_to_hub(self.sid, mid=10000) else: valid_key = True if valid_key: # format_mac, for a gateway the sid equels the mac address - mac_address = format_mac(sid) + mac_address = format_mac(self.sid) # set unique_id unique_id = mac_address @@ -172,7 +219,7 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_INTERFACE: self.interface, CONF_PROTOCOL: protocol, CONF_KEY: key, - CONF_SID: sid, + CONF_SID: self.sid, }, ) diff --git a/homeassistant/components/xiaomi_aqara/const.py b/homeassistant/components/xiaomi_aqara/const.py index ab214cb13cc..58932d7a0bc 100644 --- a/homeassistant/components/xiaomi_aqara/const.py +++ b/homeassistant/components/xiaomi_aqara/const.py @@ -13,3 +13,43 @@ CONF_KEY = "key" CONF_SID = "sid" DEFAULT_DISCOVERY_RETRY = 5 + +BATTERY_MODELS = [ + "sensor_ht", + "weather", + "weather.v1", + "sensor_motion.aq2", + "vibration", + "magnet", + "sensor_magnet", + "sensor_magnet.aq2", + "motion", + "sensor_motion", + "sensor_motion.aq2", + "switch", + "sensor_switch", + "sensor_switch.aq2", + "sensor_switch.aq3", + "remote.b1acn01", + "86sw1", + "sensor_86sw1", + "sensor_86sw1.aq1", + "remote.b186acn01", + "86sw2", + "sensor_86sw2", + "sensor_86sw2.aq1", + "remote.b286acn01", + "cube", + "sensor_cube", + "sensor_cube.aqgl01", + "smoke", + "sensor_smoke", + "sensor_wleak.aq1", + "vibration", + "vibration.aq1", + "curtain", + "curtain.aq2", + "curtain.hagl04", + "lock.aq1", + "lock.acn02", +] diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json index cb6bb376e3b..1a00fc3afd2 100644 --- a/homeassistant/components/xiaomi_aqara/manifest.json +++ b/homeassistant/components/xiaomi_aqara/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Gateway (Aqara)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_aqara", - "requirements": ["PyXiaomiGateway==0.12.4"], + "requirements": ["PyXiaomiGateway==0.13.2"], "after_dependencies": ["discovery"], "codeowners": ["@danielhiversen", "@syssi"], "zeroconf": ["_miio._udp.local."] diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index fe1eb5a80fe..3463cded56d 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -2,6 +2,8 @@ import logging from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_PRESSURE, @@ -11,7 +13,7 @@ from homeassistant.const import ( ) from . import XiaomiDevice -from .const import DOMAIN, GATEWAYS_KEY +from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY _LOGGER = logging.getLogger(__name__) @@ -79,6 +81,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) else: _LOGGER.warning("Unmapped Device Model") + + # Set up battery sensors + for devices in gateway.devices.values(): + for device in devices: + if device["model"] in BATTERY_MODELS: + entities.append( + XiaomiBatterySensor(device, "Battery", gateway, config_entry) + ) + async_add_entities(entities) @@ -144,3 +155,37 @@ class XiaomiSensor(XiaomiDevice): else: self._state = round(value, 1) return True + + +class XiaomiBatterySensor(XiaomiDevice): + """Representation of a XiaomiSensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return UNIT_PERCENTAGE + + @property + def device_class(self): + """Return the device class of this entity.""" + return DEVICE_CLASS_BATTERY + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def parse_data(self, data, raw_data): + """Parse data sent by gateway.""" + succeed = super().parse_voltage(data) + if not succeed: + return False + battery_level = int(self._device_state_attributes.pop(ATTR_BATTERY_LEVEL)) + if battery_level <= 0 or battery_level > 100: + return False + self._state = battery_level + return True + + def parse_voltage(self, data): + """Parse battery level data sent by gateway.""" + return False # Override parse_voltage to do nothing diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index 87e1d37cb93..5cbdc91a661 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -4,9 +4,11 @@ "step": { "user": { "title": "Xiaomi Aqara Gateway", - "description": "Connect to your Xiaomi Aqara Gateway", + "description": "Connect to your Xiaomi Aqara Gateway, if the IP and mac addresses are left empty, auto-discovery is used", "data": { - "interface": "The network interface to use" + "interface": "The network interface to use", + "host": "[%key:common::config_flow::data::ip%] (optional)", + "mac": "Mac Address (optional)" } }, "settings": { @@ -27,9 +29,10 @@ }, "error": { "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", - "not_found_error": "Zeroconf discovered Gateway could not be located to get the necessary information, try using the IP of the device running HomeAssistant as interface", "invalid_interface": "Invalid network interface", - "invalid_key": "Invalid gateway key" + "invalid_key": "Invalid gateway key", + "invalid_host": "Invalid [%key:common::config_flow::data::ip%]", + "invalid_mac": "Invalid Mac Address" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index 36dadefee1f..aee66e1a439 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -37,19 +37,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): device, "Plug", data_key, True, gateway, config_entry ) ) - elif model in ["ctrl_neutral1", "ctrl_neutral1.aq1"]: + elif model in ["ctrl_neutral1", "ctrl_neutral1.aq1", "switch_b1lacn02"]: entities.append( XiaomiGenericSwitch( device, "Wall Switch", "channel_0", False, gateway, config_entry ) ) - elif model in ["ctrl_ln1", "ctrl_ln1.aq1"]: + elif model in ["ctrl_ln1", "ctrl_ln1.aq1", "switch_b1nacn02"]: entities.append( XiaomiGenericSwitch( device, "Wall Switch LN", "channel_0", False, gateway, config_entry ) ) - elif model in ["ctrl_neutral2", "ctrl_neutral2.aq1"]: + elif model in ["ctrl_neutral2", "ctrl_neutral2.aq1", "switch_b2lacn02"]: entities.append( XiaomiGenericSwitch( device, @@ -70,7 +70,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_entry, ) ) - elif model in ["ctrl_ln2", "ctrl_ln2.aq1"]: + elif model in ["ctrl_ln2", "ctrl_ln2.aq1", "switch_b2nacn02"]: entities.append( XiaomiGenericSwitch( device, diff --git a/homeassistant/components/xiaomi_aqara/translations/en.json b/homeassistant/components/xiaomi_aqara/translations/en.json index 7b801e33089..b9f6fa7ab2a 100644 --- a/homeassistant/components/xiaomi_aqara/translations/en.json +++ b/homeassistant/components/xiaomi_aqara/translations/en.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Device is already configured", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "Config flow for this gateway is already in progress", "not_xiaomi_aqara": "Not a Xiaomi Aqara Gateway, discovered device did not match known gateways" }, "error": { "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", + "invalid_host": "Invalid [%key:common::config_flow::data::ip%]", "invalid_interface": "Invalid network interface", "invalid_key": "Invalid gateway key", - "not_found_error": "Zeroconf discovered Gateway could not be located to get the necessary information, try using the IP of the device running HomeAssistant as interface" + "invalid_mac": "Invalid Mac Address" }, "flow_title": "Xiaomi Aqara Gateway: {name}", "step": { @@ -30,9 +31,11 @@ }, "user": { "data": { - "interface": "The network interface to use" + "host": "[%key:common::config_flow::data::ip%] (optional)", + "interface": "The network interface to use", + "mac": "Mac Address (optional)" }, - "description": "Connect to your Xiaomi Aqara Gateway", + "description": "Connect to your Xiaomi Aqara Gateway, if the IP and mac addresses are left empty, auto-discovery is used", "title": "Xiaomi Aqara Gateway" } } diff --git a/homeassistant/components/xiaomi_aqara/translations/no.json b/homeassistant/components/xiaomi_aqara/translations/no.json index fd89ad26f93..c94d79b16d5 100644 --- a/homeassistant/components/xiaomi_aqara/translations/no.json +++ b/homeassistant/components/xiaomi_aqara/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyt for denne porten p\u00e5g\u00e5r allerede", "not_xiaomi_aqara": "Ikke en Xiaomi Aqara Gateway, oppdaget enhet ikke samsvarer med kjente gatewayer" }, diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 09719d720c0..853f8e7920b 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", "requirements": ["construct==2.9.45", "python-miio==0.5.3"], - "codeowners": ["@rytilahti", "@syssi"], + "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "zeroconf": ["_miio._udp.local."] } diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index e9d28698760..748b3da5b31 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -16,7 +16,7 @@ "name": "Nombre del Gateway", "token": "Token API" }, - "description": "Necesitar\u00e1s el Token API, consulta https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token para instrucciones.", + "description": "Necesitar\u00e1s el token de la API de 32 caracteres, revisa https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token para m\u00e1s instrucciones. Por favor, ten en cuenta que este token es diferente de la clave utilizada por la integraci\u00f3n de Xiaomi Aqara.", "title": "Conectar con un Xiaomi Gateway" }, "user": { diff --git a/homeassistant/components/xiaomi_miio/translations/no.json b/homeassistant/components/xiaomi_miio/translations/no.json index 24e070323b3..cf978d4a015 100644 --- a/homeassistant/components/xiaomi_miio/translations/no.json +++ b/homeassistant/components/xiaomi_miio/translations/no.json @@ -5,6 +5,7 @@ "already_in_progress": "Konfigurasjonsflyt for denne Xiaomi Miio-enheten p\u00e5g\u00e5r allerede." }, "error": { + "connect_error": "Tilkobling mislyktes.", "no_device_selected": "Ingen enhet valgt, vennligst velg en enhet." }, "flow_title": "Xiaomi Miio: {navn}", diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json index da9d920a26c..bb53be8f170 100644 --- a/homeassistant/components/yandex_transport/manifest.json +++ b/homeassistant/components/yandex_transport/manifest.json @@ -2,6 +2,6 @@ "domain": "yandex_transport", "name": "Yandex Transport", "documentation": "https://www.home-assistant.io/integrations/yandex_transport", - "requirements": ["ya_ma==0.3.8"], - "codeowners": ["@rishatik92"] + "requirements": ["aioymaps==1.0.0"], + "codeowners": ["@rishatik92", "@devbis"] } diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index be029715cce..cde115cb12f 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -3,11 +3,12 @@ from datetime import timedelta import logging +from aioymaps import YandexMapsRequester import voluptuous as vol -from ya_ma import YandexMapsRequester from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util @@ -35,20 +36,21 @@ 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 Yandex transport sensor.""" stop_id = config[CONF_STOP_ID] name = config[CONF_NAME] routes = config[CONF_ROUTE] - data = YandexMapsRequester(user_agent=USER_AGENT) - add_entities([DiscoverMoscowYandexTransport(data, stop_id, routes, name)], True) + client_session = async_create_clientsession(hass, requote_redirect_url=False) + data = YandexMapsRequester(user_agent=USER_AGENT, client_session=client_session) + async_add_entities([DiscoverYandexTransport(data, stop_id, routes, name)], True) -class DiscoverMoscowYandexTransport(Entity): +class DiscoverYandexTransport(Entity): """Implementation of yandex_transport sensor.""" - def __init__(self, requester, stop_id, routes, name): + def __init__(self, requester: YandexMapsRequester, stop_id, routes, name): """Initialize sensor.""" self.requester = requester self._stop_id = stop_id @@ -58,12 +60,12 @@ class DiscoverMoscowYandexTransport(Entity): self._name = name self._attrs = None - def update(self): + async def async_update(self): """Get the latest data from maps.yandex.ru and update the states.""" attrs = {} closer_time = None + yandex_reply = await self.requester.get_stop_info(self._stop_id) try: - yandex_reply = self.requester.get_stop_info(self._stop_id) data = yandex_reply["data"] except KeyError as key_error: _LOGGER.warning( @@ -71,8 +73,8 @@ class DiscoverMoscowYandexTransport(Entity): key_error, yandex_reply, ) - self.requester.set_new_session() - data = self.requester.get_stop_info(self._stop_id)["data"] + await self.requester.set_new_session() + data = (await self.requester.get_stop_info(self._stop_id))["data"] stop_name = data["name"] transport_list = data["transports"] for transport in transport_list: diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 3c448a91010..71e2f67bad7 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -21,13 +21,14 @@ from homeassistant import util from homeassistant.const import ( ATTR_NAME, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, __version__, ) -from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.singleton import singleton +from homeassistant.loader import async_get_homekit, async_get_zeroconf _LOGGER = logging.getLogger(__name__) @@ -196,8 +197,14 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, zeroconf_hass_start) + zeroconf_types = {} + homekit_models = {} + def service_update(zeroconf, service_type, name, state_change): """Service state changed.""" + nonlocal zeroconf_types + nonlocal homekit_models + if state_change != ServiceStateChange.Added: return @@ -218,7 +225,7 @@ def setup(hass, config): # If we can handle it as a HomeKit discovery, we do that here. if service_type == HOMEKIT_TYPE: - discovery_was_forwarded = handle_homekit(hass, info) + discovery_was_forwarded = handle_homekit(hass, homekit_models, info) # Continue on here as homekit_controller # still needs to get updates on devices # so it can see when the 'c#' field is updated. @@ -240,24 +247,35 @@ def setup(hass, config): # likely bad homekit data return - for domain in ZEROCONF[service_type]: + for domain in zeroconf_types[service_type]: hass.add_job( hass.config_entries.flow.async_init( domain, context={"source": DOMAIN}, data=info ) ) - types = list(ZEROCONF) + async def zeroconf_hass_started(_event): + """Start the service browser.""" + nonlocal zeroconf_types + nonlocal homekit_models - if HOMEKIT_TYPE not in ZEROCONF: - types.append(HOMEKIT_TYPE) + zeroconf_types = await async_get_zeroconf(hass) + homekit_models = await async_get_homekit(hass) - HaServiceBrowser(zeroconf, types, handlers=[service_update]) + types = list(zeroconf_types) + + if HOMEKIT_TYPE not in zeroconf_types: + types.append(HOMEKIT_TYPE) + + _LOGGER.debug("Starting Zeroconf browser") + HaServiceBrowser(zeroconf, types, handlers=[service_update]) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STARTED, zeroconf_hass_started) return True -def handle_homekit(hass, info) -> bool: +def handle_homekit(hass, homekit_models, info) -> bool: """Handle a HomeKit discovery. Return if discovery was forwarded. @@ -273,7 +291,7 @@ def handle_homekit(hass, info) -> bool: if model is None: return False - for test_model in HOMEKIT: + for test_model in homekit_models: if ( model != test_model and not model.startswith(f"{test_model} ") @@ -283,7 +301,7 @@ def handle_homekit(hass, info) -> bool: hass.add_job( hass.config_entries.flow.async_init( - HOMEKIT[test_model], context={"source": "homekit"}, data=info + homekit_models[test_model], context={"source": "homekit"}, data=info ) ) return True diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index fcc99088524..b93e47f8a65 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.27.1"], + "requirements": ["zeroconf==0.28.0"], "dependencies": ["api"], "codeowners": ["@Kane610"], "quality_scale": "internal" diff --git a/homeassistant/components/zerproc/translations/lb.json b/homeassistant/components/zerproc/translations/lb.json index e1d2e73d527..d4a2cccba4c 100644 --- a/homeassistant/components/zerproc/translations/lb.json +++ b/homeassistant/components/zerproc/translations/lb.json @@ -1,7 +1,13 @@ { "config": { "abort": { - "no_devices_found": "Keng Apparater am Netzwierk fonnt" + "no_devices_found": "Keng Apparater am Netzwierk fonnt", + "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguraioun ass m\u00e9iglech." + }, + "step": { + "confirm": { + "description": "Soll d'Konfiguratioun gestart ginn?" + } } }, "title": "Zerproc" diff --git a/homeassistant/components/zerproc/translations/no.json b/homeassistant/components/zerproc/translations/no.json index cdfd3890fb8..5324c4b87b1 100644 --- a/homeassistant/components/zerproc/translations/no.json +++ b/homeassistant/components/zerproc/translations/no.json @@ -1,3 +1,14 @@ { + "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?" + } + } + }, "title": "Zerproc" } \ No newline at end of file diff --git a/homeassistant/components/zerproc/translations/tr.json b/homeassistant/components/zerproc/translations/tr.json new file mode 100644 index 00000000000..49fa9545e94 --- /dev/null +++ b/homeassistant/components/zerproc/translations/tr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 0ed931e92da..2548060c0fd 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -80,7 +80,7 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() await self.get_device_class() - await self.async_accept_signal( + self.async_accept_signal( self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 7e2a0e147a7..7ffb727bacc 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -393,7 +393,7 @@ class Thermostat(ZhaEntity, ClimateEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._thrm, SIGNAL_ATTR_UPDATED, self.async_attribute_updated ) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 235368080f0..45114c677af 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -66,7 +66,7 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._cover_channel, SIGNAL_ATTR_UPDATED, self.async_set_position ) @@ -213,10 +213,10 @@ class Shade(ZhaEntity, CoverEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_open_closed ) - await self.async_accept_signal( + self.async_accept_signal( self._level_channel, SIGNAL_SET_LEVEL, self.async_set_level ) diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 2a53fc3bf3c..824435e6337 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -55,7 +55,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() if self._battery_channel: - await self.async_accept_signal( + self.async_accept_signal( self._battery_channel, SIGNAL_ATTR_UPDATED, self.async_battery_percentage_remaining_updated, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 2deea13e08b..d583f89c9bc 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -114,7 +114,8 @@ class BaseZhaEntity(LogMixin, entity.Entity): unsub() self._unsubs.remove(unsub) - async def async_accept_signal( + @callback + def async_accept_signal( self, channel: ChannelType, signal: str, func: CALLABLE_T, signal_override=False ): """Accept a signal from a channel.""" @@ -162,7 +163,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" self.remove_future = asyncio.Future() - await self.async_accept_signal( + self.async_accept_signal( None, f"{SIGNAL_REMOVE}_{self.zha_device.ieee}", self.async_remove, @@ -175,7 +176,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): if last_state: self.async_restore_last_state(last_state) - await self.async_accept_signal( + self.async_accept_signal( None, f"{self.zha_device.available_signal}_entity", self.async_state_changed, @@ -231,14 +232,14 @@ class ZhaGroupEntity(BaseZhaEntity): async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( None, f"{SIGNAL_REMOVE_GROUP}_0x{self._group_id:04x}", self.async_remove, signal_override=True, ) - await self.async_accept_signal( + self.async_accept_signal( None, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{self._group_id:04x}", self.async_remove, diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index c7a13a4f34f..ac8edd10be5 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -135,7 +135,7 @@ class ZhaFan(BaseFan, ZhaEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 6fefc795460..ff080562190 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -192,7 +192,7 @@ class BaseLight(LogMixin, light.LightEntity): async def async_turn_on(self, **kwargs): """Turn the entity on.""" transition = kwargs.get(light.ATTR_TRANSITION) - duration = transition * 10 if transition else 0 + duration = transition * 10 if transition else 1 brightness = kwargs.get(light.ATTR_BRIGHTNESS) effect = kwargs.get(light.ATTR_EFFECT) flash = kwargs.get(light.ATTR_FLASH) @@ -372,18 +372,18 @@ class Light(BaseLight, ZhaEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) if self._level_channel: - await self.async_accept_signal( + self.async_accept_signal( self._level_channel, SIGNAL_SET_LEVEL, self.set_level ) refresh_interval = random.randint(*[x * 60 for x in self._REFRESH_INTERVAL]) self._cancel_refresh_handle = async_track_time_interval( self.hass, self._refresh, timedelta(seconds=refresh_interval) ) - await self.async_accept_signal( + self.async_accept_signal( None, SIGNAL_LIGHT_GROUP_STATE_CHANGED, self._maybe_force_refresh, diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index d70c1e2e7f3..d951e7ada19 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -60,7 +60,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._doorlock_channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 974cac32c33..b9d2caf0137 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,9 +4,9 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.18.0", + "bellows==0.18.1", "pyserial==3.4", - "zha-quirks==0.0.42", + "zha-quirks==0.0.43", "zigpy-cc==0.4.4", "zigpy-deconz==0.9.2", "zigpy==0.22.2", diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 86969c5fe96..38a9f19dce2 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -98,10 +98,10 @@ class Sensor(ZhaEntity): await super().async_added_to_hass() self._device_state_attributes.update(await self.async_state_attr_provider()) - await self.async_accept_signal( + self.async_accept_signal( self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) - await self.async_accept_signal( + self.async_accept_signal( self._channel, SIGNAL_STATE_ATTR, self.async_update_state_attribute ) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 9a7fc7aa6b0..07c40c7175e 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -92,7 +92,7 @@ class Switch(BaseSwitch, ZhaEntity): async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index aad8eb51dd2..2a7c3f01a27 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -323,6 +323,8 @@ class Zone(entity.Entity): async def async_update_config(self, config: Dict) -> None: """Handle when the config is updated.""" + if self._config == config: + return self._config = config self._generate_attrs() self.async_write_ha_state() diff --git a/homeassistant/components/zwave/translations/es.json b/homeassistant/components/zwave/translations/es.json index 0588ab6076b..02b95f0e028 100644 --- a/homeassistant/components/zwave/translations/es.json +++ b/homeassistant/components/zwave/translations/es.json @@ -11,7 +11,7 @@ "user": { "data": { "network_key": "Clave de red (d\u00e9jelo en blanco para generar autom\u00e1ticamente)", - "usb_path": "Ruta USB" + "usb_path": "Ruta del dispositivo USB" }, "description": "Consulta https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n", "title": "Configurar Z-Wave" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 77efc3824df..f36e2c6accb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -10,7 +10,7 @@ import weakref import attr from homeassistant import data_entry_flow, loader -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import entity_registry from homeassistant.helpers.event import Event @@ -96,6 +96,9 @@ class OperationNotAllowed(ConfigError): """Raised when a config entry operation is not allowed.""" +UpdateListenerType = Callable[[HomeAssistant, "ConfigEntry"], Any] + + class ConfigEntry: """Hold a configuration entry.""" @@ -165,7 +168,7 @@ class ConfigEntry: self.unique_id = unique_id # Listeners to call on update - self.update_listeners: List = [] + self.update_listeners: List[weakref.ReferenceType[UpdateListenerType]] = [] # Function to cancel a scheduled retry self._async_cancel_retry_setup: Optional[Callable[[], Any]] = None @@ -398,11 +401,9 @@ class ConfigEntry: ) return False - def add_update_listener(self, listener: Callable) -> Callable: + def add_update_listener(self, listener: UpdateListenerType) -> CALLBACK_TYPE: """Listen for when entry is updated. - Listener: Callback function(hass, entry) - Returns function to unlisten. """ weak_listener = weakref.ref(listener) @@ -768,7 +769,8 @@ class ConfigEntries: for listener_ref in entry.update_listeners: listener = listener_ref() - self.hass.async_create_task(listener(self.hass, entry)) + if listener is not None: + self.hass.async_create_task(listener(self.hass, entry)) self._async_schedule_save() diff --git a/homeassistant/const.py b/homeassistant/const.py index e4c5b66526c..2726181612a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 113 -PATCH_VERSION = "3" +MINOR_VERSION = 114 +PATCH_VERSION = "0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1) @@ -171,6 +171,7 @@ CONF_TOKEN = "token" CONF_TRIGGER_TIME = "trigger_time" CONF_TTL = "ttl" CONF_TYPE = "type" +CONF_UNIQUE_ID = "unique_id" CONF_UNIT_OF_MEASUREMENT = "unit_of_measurement" CONF_UNIT_SYSTEM = "unit_system" CONF_UNTIL = "until" diff --git a/homeassistant/core.py b/homeassistant/core.py index a40bc18c804..da40c17c411 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -12,6 +12,7 @@ from ipaddress import ip_address import logging import os import pathlib +import random import re import threading from time import monotonic @@ -34,7 +35,6 @@ from typing import ( ) import uuid -from async_timeout import timeout import attr import voluptuous as vol import yarl @@ -75,6 +75,7 @@ from homeassistant.util import location, network from homeassistant.util.async_ import fire_coroutine_threadsafe, run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.thread import fix_threading_exception_logging +from homeassistant.util.timeout import TimeoutManager from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem # Typing imports that create a circular dependency @@ -183,10 +184,12 @@ class HomeAssistant: self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. self.data: dict = {} - self.state = CoreState.not_running - self.exit_code = 0 + self.state: CoreState = CoreState.not_running + self.exit_code: int = 0 # If not None, use to signal end-of-loop self._stopped: Optional[asyncio.Event] = None + # Timeout handler for Core/Helper namespace + self.timeout: TimeoutManager = TimeoutManager() @property def is_running(self) -> bool: @@ -254,7 +257,7 @@ class HomeAssistant: try: # Only block for EVENT_HOMEASSISTANT_START listener self.async_stop_track_tasks() - async with timeout(TIMEOUT_EVENT_START): + async with self.timeout.async_timeout(TIMEOUT_EVENT_START): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( @@ -459,17 +462,35 @@ class HomeAssistant: self.state = CoreState.stopping self.async_track_tasks() self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(120): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 1 to complete, the shutdown will continue" + ) # stage 2 self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(60): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 2 to complete, the shutdown will continue" + ) # stage 3 self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(30): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 3 to complete, the shutdown will continue" + ) # Python 3.9+ and backported in runner.py await self.loop.shutdown_default_executor() # type: ignore @@ -489,7 +510,13 @@ class Context: user_id: str = attr.ib(default=None) parent_id: Optional[str] = attr.ib(default=None) - id: str = attr.ib(factory=lambda: uuid.uuid4().hex) + # The uuid1 uses a random multicast MAC address instead of the real MAC address + # of the machine without the overhead of calling the getrandom() system call. + # + # This is effectively equivalent to PostgreSQL's uuid_generate_v1mc() function + id: str = attr.ib( + factory=lambda: uuid.uuid1(node=random.getrandbits(48) | (1 << 40)).hex + ) def as_dict(self) -> dict: """Return a dictionary representation of the context.""" @@ -1570,6 +1597,7 @@ class Config: def _async_create_timer(hass: HomeAssistant) -> None: """Create a timer that will start on HOMEASSISTANT_START.""" handle = None + timer_context = Context() def schedule_tick(now: datetime.datetime) -> None: """Schedule a timer tick when the next second rolls around.""" @@ -1584,12 +1612,14 @@ def _async_create_timer(hass: HomeAssistant) -> None: """Fire next time event.""" now = dt_util.utcnow() - hass.bus.async_fire(EVENT_TIME_CHANGED, {ATTR_NOW: now}) + hass.bus.async_fire(EVENT_TIME_CHANGED, {ATTR_NOW: now}, context=timer_context) # If we are more than a second late, a tick was missed late = monotonic() - target if late > 1: - hass.bus.async_fire(EVENT_TIMER_OUT_OF_SYNC, {ATTR_SECONDS: late}) + hass.bus.async_fire( + EVENT_TIMER_OUT_OF_SYNC, {ATTR_SECONDS: late}, context=timer_context + ) schedule_tick(now) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 23fdc656af6..3b4216377e5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -7,6 +7,7 @@ To update, run python3 -m script.hassfest FLOWS = [ "abode", + "accuweather", "acmeda", "adguard", "agent_dvr", @@ -21,6 +22,7 @@ FLOWS = [ "avri", "awair", "axis", + "azure_devops", "blebox", "blink", "bond", @@ -29,6 +31,7 @@ FLOWS = [ "bsblan", "cast", "cert_expiry", + "control4", "coolmaster", "coronavirus", "daikin", @@ -68,6 +71,7 @@ FLOWS = [ "harmony", "heos", "hisense_aehw4a1", + "hlk_sw16", "home_connect", "homekit", "homekit_controller", @@ -90,7 +94,6 @@ FLOWS = [ "konnected", "life360", "lifx", - "linky", "local_ip", "locative", "logi_circle", @@ -118,6 +121,7 @@ FLOWS = [ "onvif", "opentherm_gw", "openuv", + "ovo_energy", "owntracks", "ozw", "panasonic_viera", @@ -154,6 +158,7 @@ FLOWS = [ "songpal", "sonos", "speedtestdotnet", + "spider", "spotify", "squeezebox", "starline", @@ -181,10 +186,12 @@ FLOWS = [ "vesync", "vilfo", "vizio", + "volumio", "wemo", "wiffi", "withings", "wled", + "wolflink", "xiaomi_aqara", "xiaomi_miio", "zerproc", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 7271252c36f..d58842fe88e 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -12,6 +12,11 @@ SSDP = { "manufacturer": "ARCAM" } ], + "control4": [ + { + "st": "c4:director" + } + ], "deconz": [ { "manufacturer": "Royal Philips Electronics" diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index dc2bd289930..a61444a42c0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -6,6 +6,9 @@ To update, run python3 -m script.hassfest # fmt: off ZEROCONF = { + "_Volumio._tcp.local.": [ + "volumio" + ], "_api._udp.local.": [ "guardian" ], @@ -13,6 +16,9 @@ ZEROCONF = { "axis", "doorbird" ], + "_bond._tcp.local.": [ + "bond" + ], "_daap._tcp.local.": [ "forked_daapd" ], diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 72f4b2c5e6d..5def290766f 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -2,7 +2,7 @@ from asyncio import Event from collections import OrderedDict import logging -from typing import Iterable, MutableMapping, Optional, cast +from typing import Dict, Iterable, List, MutableMapping, Optional, cast import uuid import attr @@ -132,7 +132,7 @@ class AreaRegistry: self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict: + def _data_to_save(self) -> Dict[str, List[Dict[str, Optional[str]]]]: """Return data of area registry to store in a file.""" data = {} diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 9be584403bd..e5b113f8a4d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -86,7 +86,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name -TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM' or 'HH:MM:SS'" +TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" # Home Assistant types byte = vol.All(vol.Coerce(int), vol.Range(min=0, max=255)) @@ -299,11 +299,11 @@ time_period_dict = vol.All( dict, vol.Schema( { - "days": vol.Coerce(int), - "hours": vol.Coerce(int), - "minutes": vol.Coerce(int), - "seconds": vol.Coerce(int), - "milliseconds": vol.Coerce(int), + "days": vol.Coerce(float), + "hours": vol.Coerce(float), + "minutes": vol.Coerce(float), + "seconds": vol.Coerce(float), + "milliseconds": vol.Coerce(float), } ), has_at_least_one_key("days", "hours", "minutes", "seconds", "milliseconds"), @@ -357,17 +357,17 @@ def time_period_str(value: str) -> timedelta: elif value.startswith("+"): value = value[1:] - try: - parsed = [int(x) for x in value.split(":")] - except ValueError: + parsed = value.split(":") + if len(parsed) not in (2, 3): raise vol.Invalid(TIME_PERIOD_ERROR.format(value)) - - if len(parsed) == 2: - hour, minute = parsed - second = 0 - elif len(parsed) == 3: - hour, minute, second = parsed - else: + try: + hour = int(parsed[0]) + minute = int(parsed[1]) + try: + second = float(parsed[2]) + except IndexError: + second = 0 + except ValueError: raise vol.Invalid(TIME_PERIOD_ERROR.format(value)) offset = timedelta(hours=hour, minutes=minute, seconds=second) @@ -378,10 +378,10 @@ def time_period_str(value: str) -> timedelta: return offset -def time_period_seconds(value: Union[int, str]) -> timedelta: +def time_period_seconds(value: Union[float, str]) -> timedelta: """Validate and transform seconds to a time offset.""" try: - return timedelta(seconds=int(value)) + return timedelta(seconds=float(value)) except (ValueError, TypeError): raise vol.Invalid(f"Expected seconds, got {value}") diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index f9a7d6660da..b2e3bfd7a32 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1,7 +1,7 @@ """Provide a way to connect entities belonging to one device.""" from collections import OrderedDict import logging -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union import uuid import attr @@ -32,6 +32,11 @@ CONNECTION_NETWORK_MAC = "mac" CONNECTION_UPNP = "upnp" CONNECTION_ZIGBEE = "zigbee" +IDX_CONNECTIONS = "connections" +IDX_IDENTIFIERS = "identifiers" +REGISTERED_DEVICE = "registered" +DELETED_DEVICE = "deleted" + @attr.s(slots=True, frozen=True) class DeletedDeviceEntry: @@ -98,11 +103,13 @@ class DeviceRegistry: devices: Dict[str, DeviceEntry] deleted_devices: Dict[str, DeletedDeviceEntry] + _devices_index: Dict[str, Dict[str, Dict[str, str]]] def __init__(self, hass: HomeAssistantType) -> None: """Initialize the device registry.""" self.hass = hass self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._clear_index() @callback def async_get(self, device_id: str) -> Optional[DeviceEntry]: @@ -114,25 +121,84 @@ class DeviceRegistry: self, identifiers: set, connections: set ) -> Optional[DeviceEntry]: """Check if device is registered.""" - for device in self.devices.values(): - if any(iden in device.identifiers for iden in identifiers) or any( - conn in device.connections for conn in connections - ): - return device - return None + device_id = self._async_get_device_id_from_index( + REGISTERED_DEVICE, identifiers, connections + ) + if device_id is None: + return None + return self.devices[device_id] - @callback def _async_get_deleted_device( self, identifiers: set, connections: set ) -> Optional[DeletedDeviceEntry]: + """Check if device is deleted.""" + device_id = self._async_get_device_id_from_index( + DELETED_DEVICE, identifiers, connections + ) + if device_id is None: + return None + return self.deleted_devices[device_id] + + def _async_get_device_id_from_index( + self, index: str, identifiers: set, connections: set + ) -> Optional[str]: """Check if device has previously been registered.""" - for device in self.deleted_devices.values(): - if any(iden in device.identifiers for iden in identifiers) or any( - conn in device.connections for conn in connections - ): - return device + devices_index = self._devices_index[index] + for identifier in identifiers: + if identifier in devices_index[IDX_IDENTIFIERS]: + return devices_index[IDX_IDENTIFIERS][identifier] + if not connections: + return None + for connection in _normalize_connections(connections): + if connection in devices_index[IDX_CONNECTIONS]: + return devices_index[IDX_CONNECTIONS][connection] return None + def _add_device(self, device: Union[DeviceEntry, DeletedDeviceEntry]) -> None: + """Add a device and index it.""" + if isinstance(device, DeletedDeviceEntry): + devices_index = self._devices_index[DELETED_DEVICE] + self.deleted_devices[device.id] = device + else: + devices_index = self._devices_index[REGISTERED_DEVICE] + self.devices[device.id] = device + + _add_device_to_index(devices_index, device) + + def _remove_device(self, device: Union[DeviceEntry, DeletedDeviceEntry]) -> None: + """Remove a device and remove it from the index.""" + if isinstance(device, DeletedDeviceEntry): + devices_index = self._devices_index[DELETED_DEVICE] + self.deleted_devices.pop(device.id) + else: + devices_index = self._devices_index[REGISTERED_DEVICE] + self.devices.pop(device.id) + + _remove_device_from_index(devices_index, device) + + def _update_device(self, old_device: DeviceEntry, new_device: DeviceEntry) -> None: + """Update a device and the index.""" + self.devices[new_device.id] = new_device + + devices_index = self._devices_index[REGISTERED_DEVICE] + _remove_device_from_index(devices_index, old_device) + _add_device_to_index(devices_index, new_device) + + def _clear_index(self): + """Clear the index.""" + self._devices_index = { + REGISTERED_DEVICE: {IDX_IDENTIFIERS: {}, IDX_CONNECTIONS: {}}, + DELETED_DEVICE: {IDX_IDENTIFIERS: {}, IDX_CONNECTIONS: {}}, + } + + def _rebuild_index(self): + """Create the index after loading devices.""" + self._clear_index() + for device in self.devices.values(): + _add_device_to_index(self._devices_index[REGISTERED_DEVICE], device) + for device in self.deleted_devices.values(): + _add_device_to_index(self._devices_index[DELETED_DEVICE], device) + @callback def async_get_or_create( self, @@ -156,11 +222,8 @@ class DeviceRegistry: if connections is None: connections = set() - - connections = { - (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) - for key, value in connections - } + else: + connections = _normalize_connections(connections) device = self.async_get_device(identifiers, connections) @@ -169,9 +232,9 @@ class DeviceRegistry: if deleted_device is None: device = DeviceEntry(is_new=True) else: - self.deleted_devices.pop(deleted_device.id) + self._remove_device(deleted_device) device = deleted_device.to_device_entry() - self.devices[device.id] = device + self._add_device(device) if via_device is not None: via = self.async_get_device({via_device}, set()) @@ -301,7 +364,8 @@ class DeviceRegistry: if not changes: return old - new = self.devices[device_id] = attr.evolve(old, **changes) + new = attr.evolve(old, **changes) + self._update_device(old, new) self.async_schedule_save() self.hass.bus.async_fire( @@ -317,12 +381,15 @@ class DeviceRegistry: @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" - device = self.devices.pop(device_id) - self.deleted_devices[device_id] = DeletedDeviceEntry( - config_entries=device.config_entries, - connections=device.connections, - identifiers=device.identifiers, - id=device.id, + device = self.devices[device_id] + self._remove_device(device) + self._add_device( + DeletedDeviceEntry( + config_entries=device.config_entries, + connections=device.connections, + identifiers=device.identifiers, + id=device.id, + ) ) self.hass.bus.async_fire( EVENT_DEVICE_REGISTRY_UPDATED, {"action": "remove", "device_id": device_id} @@ -371,6 +438,7 @@ class DeviceRegistry: self.devices = devices self.deleted_devices = deleted_devices + self._rebuild_index() @callback def async_schedule_save(self) -> None: @@ -422,9 +490,11 @@ class DeviceRegistry: continue if config_entries == {config_entry_id}: # Permanently remove the device from the device registry. - del self.deleted_devices[deleted_device.id] + self._remove_device(deleted_device) else: config_entries = config_entries - {config_entry_id} + # No need to reindex here since we currently + # do not have a lookup by config entry self.deleted_devices[deleted_device.id] = attr.evolve( deleted_device, config_entries=config_entries ) @@ -536,3 +606,33 @@ def async_setup_cleanup(hass: HomeAssistantType, dev_reg: DeviceRegistry) -> Non await debounced_cleanup.async_call() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean) + + +def _normalize_connections(connections: set) -> set: + """Normalize connections to ensure we can match mac addresses.""" + return { + (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) + for key, value in connections + } + + +def _add_device_to_index( + devices_index: dict, device: Union[DeviceEntry, DeletedDeviceEntry] +) -> None: + """Add a device to the index.""" + for identifier in device.identifiers: + devices_index[IDX_IDENTIFIERS][identifier] = device.id + for connection in device.connections: + devices_index[IDX_CONNECTIONS][connection] = device.id + + +def _remove_device_from_index( + devices_index: dict, device: Union[DeviceEntry, DeletedDeviceEntry] +) -> None: + """Remove a device from the index.""" + for identifier in device.identifiers: + if identifier in devices_index[IDX_IDENTIFIERS]: + del devices_index[IDX_IDENTIFIERS][identifier] + for connection in device.connections: + if connection in devices_index[IDX_CONNECTIONS]: + del devices_index[IDX_CONNECTIONS][connection] diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 8414bb912c2..4fd54f4ee8a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -27,13 +27,9 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.helpers.entity_platform import EntityPlatform -from homeassistant.helpers.entity_registry import ( - EVENT_ENTITY_REGISTRY_UPDATED, - RegistryEntry, -) -from homeassistant.helpers.event import Event +from homeassistant.helpers.entity_registry import RegistryEntry +from homeassistant.helpers.event import Event, async_track_entity_registry_updated_event from homeassistant.util import dt as dt_util, ensure_unique_string, slugify -from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 @@ -46,21 +42,7 @@ def generate_entity_id( hass: Optional[HomeAssistant] = None, ) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" - if current_ids is None: - if hass is None: - raise ValueError("Missing required parameter currentids or hass") - return run_callback_threadsafe( - hass.loop, - async_generate_entity_id, - entity_id_format, - name, - current_ids, - hass, - ).result() - - name = (slugify(name or "") or slugify(DEVICE_DEFAULT_NAME)).lower() - - return ensure_unique_string(entity_id_format.format(name), current_ids) + return async_generate_entity_id(entity_id_format, name, current_ids, hass) @callback @@ -71,14 +53,23 @@ def async_generate_entity_id( hass: Optional[HomeAssistant] = None, ) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" - if current_ids is None: - if hass is None: - raise ValueError("Missing required parameter currentids or hass") - current_ids = hass.states.async_entity_ids() name = (name or DEVICE_DEFAULT_NAME).lower() + preferred_string = entity_id_format.format(slugify(name)) - return ensure_unique_string(entity_id_format.format(slugify(name)), current_ids) + if current_ids is not None: + return ensure_unique_string(preferred_string, current_ids) + + if hass is None: + raise ValueError("Missing required parameter current_ids or hass") + + test_string = preferred_string + tries = 1 + while hass.states.get(test_string): + tries += 1 + test_string = f"{preferred_string}_{tries}" + + return test_string class Entity(ABC): @@ -518,8 +509,8 @@ class Entity(ABC): if self.registry_entry is not None: assert self.hass is not None self.async_on_remove( - self.hass.bus.async_listen( - EVENT_ENTITY_REGISTRY_UPDATED, self._async_registry_updated + async_track_entity_registry_updated_event( + self.hass, self.entity_id, self._async_registry_updated ) ) @@ -532,14 +523,11 @@ class Entity(ABC): async def _async_registry_updated(self, event: Event) -> None: """Handle entity registry update.""" data = event.data - if data["action"] == "remove" and data["entity_id"] == self.entity_id: + if data["action"] == "remove": await self.async_removed_from_registry() await self.async_remove() - if ( - data["action"] != "update" - or data.get("old_entity_id", data["entity_id"]) != self.entity_id - ): + if data["action"] != "update": return assert self.hass is not None diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ee60563ba2a..6d9a1275b06 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -23,6 +23,9 @@ if TYPE_CHECKING: SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 60 +SLOW_ADD_ENTITY_MAX_WAIT = 10 # Per Entity +SLOW_ADD_MIN_TIMEOUT = 60 + PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds @@ -79,7 +82,8 @@ class EntityPlatform: If parallel updates is set to 0, we skip the semaphore. If parallel updates is set to a number, we initialize the semaphore to that number. - Default for entities with `async_update` method is 1. Otherwise it's 0. + The default value for parallel requests is decided based on the first entity that is added to Home Assistant. + It's 0 if the entity defines the async_update method, else it's 1. """ if self.parallel_updates_created: return self.parallel_updates @@ -176,7 +180,8 @@ class EntityPlatform: try: task = async_create_setup_task() - await asyncio.wait_for(asyncio.shield(task), SLOW_SETUP_MAX_WAIT) + async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, self.domain): + await asyncio.shield(task) # Block till all entities are done if self._tasks: @@ -290,7 +295,17 @@ class EntityPlatform: if not tasks: return - await asyncio.gather(*tasks) + timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(tasks), SLOW_ADD_MIN_TIMEOUT) + try: + async with self.hass.timeout.async_timeout(timeout, self.domain): + await asyncio.gather(*tasks) + except asyncio.TimeoutError: + self.logger.warning( + "Timed out adding entities for domain %s with platform %s after %ds", + self.domain, + self.platform_name, + timeout, + ) if self._async_unsub_polling is not None or not any( entity.should_poll for entity in self.entities.values() @@ -324,11 +339,13 @@ class EntityPlatform: entity.platform = None return + requested_entity_id = None suggested_object_id = None # Get entity_id from unique ID registration if entity.unique_id is not None: if entity.entity_id is not None: + requested_entity_id = entity.entity_id suggested_object_id = split_entity_id(entity.entity_id)[1] else: suggested_object_id = entity.name @@ -434,9 +451,14 @@ class EntityPlatform: already_exists = True if already_exists: - msg = f"Entity id already exists - ignoring: {entity.entity_id}" if entity.unique_id is not None: - msg += f". Platform {self.platform_name} does not generate unique IDs" + msg = f"Platform {self.platform_name} does not generate unique IDs. " + if requested_entity_id: + msg += f"ID {entity.unique_id} is already used by {entity.entity_id} - ignoring {requested_entity_id}" + else: + msg += f"ID {entity.unique_id} already exists - ignoring {entity.entity_id}" + else: + msg = f"Entity id already exists - ignoring: {entity.entity_id}" self.logger.error(msg) entity.hass = None entity.platform = None diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b9b9af6f5c1..c4c445b2be9 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -124,6 +124,7 @@ class EntityRegistry: """Initialize the registry.""" self.hass = hass self.entities: Dict[str, RegistryEntry] + self._index: Dict[Tuple[str, str, str], str] = {} self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self.hass.bus.async_listen( EVENT_DEVICE_REGISTRY_UPDATED, self.async_device_removed @@ -160,14 +161,7 @@ class EntityRegistry: self, domain: str, platform: str, unique_id: str ) -> Optional[str]: """Check if an entity_id is currently registered.""" - for entity in self.entities.values(): - if ( - entity.domain == domain - and entity.platform == platform - and entity.unique_id == unique_id - ): - return entity.entity_id - return None + return self._index.get((domain, platform, unique_id)) @callback def async_generate_entity_id( @@ -270,7 +264,7 @@ class EntityRegistry: original_name=original_name, original_icon=original_icon, ) - self.entities[entity_id] = entity + self._register_entry(entity) _LOGGER.info("Registered new %s.%s entity: %s", domain, platform, entity_id) self.async_schedule_save() @@ -283,7 +277,7 @@ class EntityRegistry: @callback def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" - self.entities.pop(entity_id) + self._unregister_entry(self.entities[entity_id]) self.hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, {"action": "remove", "entity_id": entity_id} ) @@ -380,27 +374,22 @@ class EntityRegistry: entity_id = changes["entity_id"] = new_entity_id if new_unique_id is not _UNDEF: - conflict = next( - ( - entity - for entity in self.entities.values() - if entity.unique_id == new_unique_id - and entity.domain == old.domain - and entity.platform == old.platform - ), - None, + conflict_entity_id = self.async_get_entity_id( + old.domain, old.platform, new_unique_id ) - if conflict: + if conflict_entity_id: raise ValueError( f"Unique id '{new_unique_id}' is already in use by " - f"'{conflict.entity_id}'" + f"'{conflict_entity_id}'" ) changes["unique_id"] = new_unique_id if not changes: return old - new = self.entities[entity_id] = attr.evolve(old, **changes) + self._remove_index(old) + new = attr.evolve(old, **changes) + self._register_entry(new) self.async_schedule_save() @@ -451,6 +440,7 @@ class EntityRegistry: ) self.entities = entities + self._rebuild_index() @callback def async_schedule_save(self) -> None: @@ -494,6 +484,25 @@ class EntityRegistry: ]: self.async_remove(entity_id) + def _register_entry(self, entry: RegistryEntry) -> None: + self.entities[entry.entity_id] = entry + self._add_index(entry) + + def _add_index(self, entry: RegistryEntry) -> None: + self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id + + def _unregister_entry(self, entry: RegistryEntry) -> None: + self._remove_index(entry) + del self.entities[entry.entity_id] + + def _remove_index(self, entry: RegistryEntry) -> None: + del self._index[(entry.domain, entry.platform, entry.unique_id)] + + def _rebuild_index(self) -> None: + self._index = {} + for entry in self.entities.values(): + self._add_index(entry) + @singleton(DATA_REGISTRY) async def async_get_registry(hass: HomeAssistantType) -> EntityRegistry: diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index dfcbbeb4cd0..608fae0242e 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -102,12 +102,12 @@ INCLUDE_EXCLUDE_FILTER_SCHEMA = vol.All( ) -def _glob_to_re(glob: str) -> Pattern: +def _glob_to_re(glob: str) -> Pattern[str]: """Translate and compile glob string into pattern.""" return re.compile(fnmatch.translate(glob)) -def _test_against_patterns(patterns: List[Pattern], entity_id: str) -> bool: +def _test_against_patterns(patterns: List[Pattern[str]], entity_id: str) -> bool: """Test entity against list of patterns, true if any match.""" for pattern in patterns: if pattern.match(entity_id): diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index a2dfcff7699..3f0c2db3b2f 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1,4 +1,5 @@ """Helpers for listening to events.""" +import asyncio from datetime import datetime, timedelta import functools as ft import logging @@ -17,6 +18,7 @@ from homeassistant.const import ( SUN_EVENT_SUNSET, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.sun import get_astral_event_next from homeassistant.helpers.template import Template from homeassistant.loader import bind_hass @@ -26,6 +28,9 @@ from homeassistant.util.async_ import run_callback_threadsafe TRACK_STATE_CHANGE_CALLBACKS = "track_state_change_callbacks" TRACK_STATE_CHANGE_LISTENER = "track_state_change_listener" +TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS = "track_entity_registry_updated_callbacks" +TRACK_ENTITY_REGISTRY_UPDATED_LISTENER = "track_entity_registry_updated_listener" + _LOGGER = logging.getLogger(__name__) # PyLint does not like the use of threaded_listener_factory @@ -137,7 +142,7 @@ track_state_change = threaded_listener_factory(async_track_state_change) def async_track_state_change_event( hass: HomeAssistant, entity_ids: Union[str, Iterable[str]], - action: Callable[[Event], None], + action: Callable[[Event], Any], ) -> Callable[[], None]: """Track specific state change events indexed by entity_id. @@ -186,17 +191,28 @@ def async_track_state_change_event( @callback def remove_listener() -> None: """Remove state change listener.""" - _async_remove_state_change_listeners(hass, entity_ids, action) + _async_remove_entity_listeners( + hass, + TRACK_STATE_CHANGE_CALLBACKS, + TRACK_STATE_CHANGE_LISTENER, + entity_ids, + action, + ) return remove_listener @callback -def _async_remove_state_change_listeners( - hass: HomeAssistant, entity_ids: Iterable[str], action: Callable[[Event], None] +def _async_remove_entity_listeners( + hass: HomeAssistant, + storage_key: str, + listener_key: str, + entity_ids: Iterable[str], + action: Callable[[Event], Any], ) -> None: """Remove a listener.""" - entity_callbacks = hass.data[TRACK_STATE_CHANGE_CALLBACKS] + + entity_callbacks = hass.data[storage_key] for entity_id in entity_ids: entity_callbacks[entity_id].remove(action) @@ -204,8 +220,66 @@ def _async_remove_state_change_listeners( del entity_callbacks[entity_id] if not entity_callbacks: - hass.data[TRACK_STATE_CHANGE_LISTENER]() - del hass.data[TRACK_STATE_CHANGE_LISTENER] + hass.data[listener_key]() + del hass.data[listener_key] + + +@bind_hass +def async_track_entity_registry_updated_event( + hass: HomeAssistant, + entity_ids: Union[str, Iterable[str]], + action: Callable[[Event], Any], +) -> Callable[[], None]: + """Track specific entity registry updated events indexed by entity_id. + + Similar to async_track_state_change_event. + """ + + entity_callbacks = hass.data.setdefault(TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}) + + if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data: + + @callback + def _async_entity_registry_updated_dispatcher(event: Event) -> None: + """Dispatch entity registry updates by entity_id.""" + entity_id = event.data.get("old_entity_id", event.data["entity_id"]) + + if entity_id not in entity_callbacks: + return + + for action in entity_callbacks[entity_id][:]: + try: + hass.async_run_job(action, event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error while processing entity registry update for %s", + entity_id, + ) + + hass.data[TRACK_ENTITY_REGISTRY_UPDATED_LISTENER] = hass.bus.async_listen( + EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_registry_updated_dispatcher + ) + + if isinstance(entity_ids, str): + entity_ids = [entity_ids] + + entity_ids = [entity_id.lower() for entity_id in entity_ids] + + for entity_id in entity_ids: + entity_callbacks.setdefault(entity_id, []).append(action) + + @callback + def remove_listener() -> None: + """Remove state change listener.""" + _async_remove_entity_listeners( + hass, + TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, + TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, + entity_ids, + action, + ) + + return remove_listener @callback @@ -317,14 +391,13 @@ def async_track_point_in_time( hass: HomeAssistant, action: Callable[..., None], point_in_time: datetime ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" - utc_point_in_time = dt_util.as_utc(point_in_time) @callback def utc_converter(utc_now: datetime) -> None: """Convert passed in UTC now to local now.""" hass.async_run_job(action, dt_util.as_local(utc_now)) - return async_track_point_in_utc_time(hass, utc_converter, utc_point_in_time) + return async_track_point_in_utc_time(hass, utc_converter, point_in_time) track_point_in_time = threaded_listener_factory(async_track_point_in_time) @@ -337,13 +410,13 @@ def async_track_point_in_utc_time( ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC - point_in_time = dt_util.as_utc(point_in_time) + utc_point_in_time = dt_util.as_utc(point_in_time) cancel_callback = hass.loop.call_at( hass.loop.time() + point_in_time.timestamp() - time.time(), hass.async_run_job, action, - point_in_time, + utc_point_in_time, ) @callback @@ -491,6 +564,9 @@ def async_track_sunset( track_sunset = threaded_listener_factory(async_track_sunset) +# For targeted patching in tests +pattern_utc_now = dt_util.utcnow + @callback @bind_hass @@ -518,7 +594,7 @@ def async_track_utc_time_change( matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_hours = dt_util.parse_time_expression(hour, 0, 23) - next_time = None + next_time: datetime = dt_util.utcnow() def calculate_next(now: datetime) -> None: """Calculate and set the next time the trigger should fire.""" @@ -531,29 +607,37 @@ def async_track_utc_time_change( # Make sure rolling back the clock doesn't prevent the timer from # triggering. - last_now: Optional[datetime] = None + cancel_callback: Optional[asyncio.TimerHandle] = None + calculate_next(next_time) @callback - def pattern_time_change_listener(event: Event) -> None: + def pattern_time_change_listener() -> None: """Listen for matching time_changed events.""" - nonlocal next_time, last_now + nonlocal next_time, cancel_callback - now = event.data[ATTR_NOW] + now = pattern_utc_now() + hass.async_run_job(action, dt_util.as_local(now) if local else now) - if last_now is None or now < last_now: - # Time rolled back or next time not yet calculated - calculate_next(now) + calculate_next(now + timedelta(seconds=1)) - last_now = now + cancel_callback = hass.loop.call_at( + hass.loop.time() + next_time.timestamp() - time.time(), + pattern_time_change_listener, + ) - if next_time <= now: - hass.async_run_job(action, dt_util.as_local(now) if local else now) - calculate_next(now + timedelta(seconds=1)) + cancel_callback = hass.loop.call_at( + hass.loop.time() + next_time.timestamp() - time.time(), + pattern_time_change_listener, + ) - # We can't use async_track_point_in_utc_time here because it would - # break in the case that the system time abruptly jumps backwards. - # Our custom last_now logic takes care of resolving that scenario. - return hass.bus.async_listen(EVENT_TIME_CHANGED, pattern_time_change_listener) + @callback + def unsub_pattern_time_change_listener() -> None: + """Cancel the call_later.""" + nonlocal cancel_callback + assert cancel_callback is not None + cancel_callback.cancel() + + return unsub_pattern_time_change_listener track_utc_time_change = threaded_listener_factory(async_track_utc_time_change) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 6929d8a3bd3..6fed54227a3 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -176,8 +176,6 @@ class _ScriptRun: try: if self._stop.is_set(): return - self._script.last_triggered = utcnow() - self._changed() self._log("Running script") for self._step, self._action in enumerate(self._script.sequence): if self._stop.is_set(): @@ -644,13 +642,7 @@ class Script: self.name = name self.change_listener = change_listener self.script_mode = script_mode - if logger: - self._logger = logger - else: - logger_name = __name__ - if name: - logger_name = ".".join([logger_name, slugify(name)]) - self._logger = logging.getLogger(logger_name) + self._set_logger(logger) self._log_exceptions = log_exceptions self.last_action = None @@ -662,12 +654,30 @@ class Script: self._queue_lck = asyncio.Lock() self._config_cache: Dict[Set[Tuple], Callable[..., bool]] = {} self._repeat_script: Dict[int, Script] = {} - self._choose_data: Dict[ - int, List[Tuple[List[Callable[[HomeAssistant, Dict], bool]], Script]] - ] = {} + self._choose_data: Dict[int, Dict[str, Any]] = {} self._referenced_entities: Optional[Set[str]] = None self._referenced_devices: Optional[Set[str]] = None + def _set_logger(self, logger: Optional[logging.Logger] = None) -> None: + if logger: + self._logger = logger + else: + logger_name = __name__ + if self.name: + logger_name = ".".join([logger_name, slugify(self.name)]) + self._logger = logging.getLogger(logger_name) + + def update_logger(self, logger: Optional[logging.Logger] = None) -> None: + """Update logger.""" + self._set_logger(logger) + for script in self._repeat_script.values(): + script.update_logger(self._logger) + for choose_data in self._choose_data.values(): + for _, script in choose_data["choices"]: + script.update_logger(self._logger) + if choose_data["default"]: + choose_data["default"].update_logger(self._logger) + def _changed(self): if self.change_listener: self._hass.async_run_job(self.change_listener) @@ -785,6 +795,8 @@ class Script: self._hass, self, cast(dict, variables), context, self._log_exceptions ) self._runs.append(run) + self.last_triggered = utcnow() + self._changed() try: await asyncio.shield(run.async_run()) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index d2b4c334937..13525b4dab1 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -95,8 +95,7 @@ class Store: the second call will wait and return the result of the first call. """ if self._load_task is None: - self._load_task = self.hass.async_add_job(self._async_load()) - assert self._load_task is not None + self._load_task = self.hass.async_create_task(self._async_load()) return await self._load_task diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 855b6153ba0..12fc07dfbd8 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -1,7 +1,7 @@ """Helper to gather system info.""" import os import platform -from typing import Dict +from typing import Any, Dict from homeassistant.const import __version__ as current_version from homeassistant.loader import bind_hass @@ -11,7 +11,7 @@ from .typing import HomeAssistantType @bind_hass -async def async_get_system_info(hass: HomeAssistantType) -> Dict: +async def async_get_system_info(hass: HomeAssistantType) -> Dict[str, Any]: """Return info about the system.""" info_object = { "installation_type": "Unknown", diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 618bc6ea4f7..ef0b578811e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -10,6 +10,7 @@ import random import re from typing import Any, Dict, Iterable, List, Optional, Union from urllib.parse import urlencode as urllib_urlencode +import weakref import jinja2 from jinja2 import contextfilter, contextfunction @@ -958,6 +959,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): """Initialise template environment.""" super().__init__() self.hass = hass + self.template_cache = weakref.WeakValueDictionary() self.filters["round"] = forgiving_round self.filters["multiply"] = multiply self.filters["log"] = logarithm @@ -1042,5 +1044,25 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): """Test if attribute is safe.""" return isinstance(obj, Namespace) or super().is_safe_attribute(obj, attr, value) + def compile(self, source, name=None, filename=None, raw=False, defer_init=False): + """Compile the template.""" + if ( + name is not None + or filename is not None + or raw is not False + or defer_init is not False + ): + # If there are any non-default keywords args, we do + # not cache. In prodution we currently do not have + # any instance of this. + return super().compile(source, name, filename, raw, defer_init) + + cached = self.template_cache.get(source) + + if cached is None: + cached = self.template_cache[source] = super().compile(source) + + return cached + _NO_HASS_ENV = TemplateEnvironment(None) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index b7a36379107..7b7e6af4d62 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -3,9 +3,11 @@ import asyncio from datetime import datetime, timedelta import logging from time import monotonic -from typing import Any, Awaitable, Callable, List, Optional +from typing import Awaitable, Callable, Generic, List, Optional, TypeVar +import urllib.error import aiohttp +import requests from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event @@ -16,12 +18,14 @@ from .debounce import Debouncer REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_IMMEDIATE = True +T = TypeVar("T") + class UpdateFailed(Exception): """Raised when an update has failed.""" -class DataUpdateCoordinator: +class DataUpdateCoordinator(Generic[T]): """Class to manage fetching data from single endpoint.""" def __init__( @@ -31,7 +35,7 @@ class DataUpdateCoordinator: *, name: str, update_interval: Optional[timedelta] = None, - update_method: Optional[Callable[[], Awaitable]] = None, + update_method: Optional[Callable[[], Awaitable[T]]] = None, request_refresh_debouncer: Optional[Debouncer] = None, ): """Initialize global data updater.""" @@ -41,7 +45,7 @@ class DataUpdateCoordinator: self.update_method = update_method self.update_interval = update_interval - self.data: Optional[Any] = None + self.data: Optional[T] = None self._listeners: List[CALLBACK_TYPE] = [] self._unsub_refresh: Optional[CALLBACK_TYPE] = None @@ -120,7 +124,7 @@ class DataUpdateCoordinator: """ await self._debounced_refresh.async_call() - async def _async_update_data(self) -> Optional[Any]: + async def _async_update_data(self) -> Optional[T]: """Fetch the latest data from the source.""" if self.update_method is None: raise NotImplementedError("Update method not implemented") @@ -138,16 +142,24 @@ class DataUpdateCoordinator: start = monotonic() self.data = await self._async_update_data() - except asyncio.TimeoutError: + except (asyncio.TimeoutError, requests.exceptions.Timeout): if self.last_update_success: self.logger.error("Timeout fetching %s data", self.name) self.last_update_success = False - except aiohttp.ClientError as err: + except (aiohttp.ClientError, requests.exceptions.RequestException) as err: if self.last_update_success: self.logger.error("Error requesting %s data: %s", self.name, err) self.last_update_success = False + except urllib.error.URLError as err: + if self.last_update_success: + if err.reason == "timed out": + self.logger.error("Timeout fetching %s data", self.name) + else: + self.logger.error("Error requesting %s data: %s", self.name, err) + self.last_update_success = False + except UpdateFailed as err: if self.last_update_success: self.logger.error("Error fetching %s data: %s", self.name, err) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 315165bf27f..b82f2c0109a 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,6 +25,9 @@ from typing import ( cast, ) +from homeassistant.generated.ssdp import SSDP +from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF + # Typing imports that create a circular dependency if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -142,6 +145,56 @@ async def async_get_config_flows(hass: "HomeAssistant") -> Set[str]: return flows +async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List]: + """Return cached list of zeroconf types.""" + zeroconf: Dict[str, List] = ZEROCONF.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if not integration.zeroconf: + continue + for typ in integration.zeroconf: + zeroconf.setdefault(typ, []) + if integration.domain not in zeroconf[typ]: + zeroconf[typ].append(integration.domain) + + return zeroconf + + +async def async_get_homekit(hass: "HomeAssistant") -> Dict[str, str]: + """Return cached list of homekit models.""" + + homekit: Dict[str, str] = HOMEKIT.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if ( + not integration.homekit + or "models" not in integration.homekit + or not integration.homekit["models"] + ): + continue + for model in integration.homekit["models"]: + homekit[model] = integration.domain + + return homekit + + +async def async_get_ssdp(hass: "HomeAssistant") -> Dict[str, List]: + """Return cached list of ssdp mappings.""" + + ssdp: Dict[str, List] = SSDP.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if not integration.ssdp: + continue + + ssdp[integration.domain] = integration.ssdp + + return ssdp + + class Integration: """An integration in Home Assistant.""" @@ -258,6 +311,21 @@ class Integration: """Return Integration Quality Scale.""" return cast(str, self.manifest.get("quality_scale")) + @property + def ssdp(self) -> Optional[list]: + """Return Integration SSDP entries.""" + return cast(List[dict], self.manifest.get("ssdp")) + + @property + def zeroconf(self) -> Optional[list]: + """Return Integration zeroconf entries.""" + return cast(List[str], self.manifest.get("zeroconf")) + + @property + def homekit(self) -> Optional[dict]: + """Return Integration homekit entries.""" + return cast(Dict[str, List], self.manifest.get("homekit")) + @property def is_built_in(self) -> bool: """Test if package is a built-in integration.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7642ef3480f..e709bf68aa4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ PyJWT==1.7.1 PyNaCl==1.3.0 -aiohttp==3.6.1 +aiohttp==3.6.2 aiohttp_cors==0.7.0 astral==1.10.1 async_timeout==3.0.1 @@ -12,14 +12,14 @@ cryptography==2.9.2 defusedxml==0.6.0 distro==1.5.0 emoji==0.5.4 -hass-nabucasa==0.34.7 -home-assistant-frontend==20200716.0 +hass-nabucasa==0.35.0 +home-assistant-frontend==20200811.0 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 netdisco==2.8.1 paho-mqtt==1.5.0 pip>=8.0.3 -python-slugify==4.0.0 +python-slugify==4.0.1 pytz>=2020.1 pyyaml==5.3.1 requests==2.24.0 @@ -28,7 +28,7 @@ sqlalchemy==1.3.18 voluptuous-serialize==2.4.0 voluptuous==0.11.7 yarl==1.4.2 -zeroconf==0.27.1 +zeroconf==0.28.0 pycryptodome>=3.6.6 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 0b4560c8ac3..303f6219cae 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -2,7 +2,6 @@ import asyncio import logging import os -from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Set, Union, cast from homeassistant.core import HomeAssistant @@ -14,7 +13,6 @@ DATA_PIP_LOCK = "pip_lock" DATA_PKG_CACHE = "pkg_cache" DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs" CONSTRAINT_FILE = "package_constraints.txt" -PROGRESS_FILE = ".pip_progress" _LOGGER = logging.getLogger(__name__) DISCOVERY_INTEGRATIONS: Dict[str, Iterable[str]] = { "ssdp": ("ssdp",), @@ -124,22 +122,16 @@ async def async_process_requirements( if pkg_util.is_installed(req): continue - ret = await hass.async_add_executor_job(_install, hass, req, kwargs) + def _install(req: str, kwargs: Dict) -> bool: + """Install requirement.""" + return pkg_util.install_package(req, **kwargs) + + ret = await hass.async_add_executor_job(_install, req, kwargs) if not ret: raise RequirementsNotFound(name, [req]) -def _install(hass: HomeAssistant, req: str, kwargs: Dict) -> bool: - """Install requirement.""" - progress_path = Path(hass.config.path(PROGRESS_FILE)) - progress_path.touch() - try: - return pkg_util.install_package(req, **kwargs) - finally: - progress_path.unlink() - - def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]: """Return keyword arguments for PIP install.""" is_docker = pkg_util.is_docker_env() diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ae68727daf5..26e7bab7616 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -37,7 +37,7 @@ else: PolicyBase = asyncio.DefaultEventLoopPolicy # pylint: disable=invalid-name -class HassEventLoopPolicy(PolicyBase): +class HassEventLoopPolicy(PolicyBase): # type: ignore """Event loop policy for Home Assistant.""" def __init__(self, debug: bool) -> None: @@ -48,11 +48,11 @@ class HassEventLoopPolicy(PolicyBase): @property def loop_name(self) -> str: """Return name of the loop.""" - return self._loop_factory.__name__ + return self._loop_factory.__name__ # type: ignore - def new_event_loop(self): + def new_event_loop(self) -> asyncio.AbstractEventLoop: """Get the event loop.""" - loop = super().new_event_loop() + loop: asyncio.AbstractEventLoop = super().new_event_loop() loop.set_exception_handler(_async_loop_exception_handler) if self.debug: loop.set_debug(True) @@ -68,14 +68,14 @@ class HassEventLoopPolicy(PolicyBase): return loop # Copied from Python 3.9 source - def _do_shutdown(future): + def _do_shutdown(future: asyncio.Future) -> None: try: executor.shutdown(wait=True) loop.call_soon_threadsafe(future.set_result, None) except Exception as ex: # pylint: disable=broad-except loop.call_soon_threadsafe(future.set_exception, ex) - async def shutdown_default_executor(): + async def shutdown_default_executor() -> None: """Schedule the shutdown of the default executor.""" future = loop.create_future() thread = threading.Thread(target=_do_shutdown, args=(future,)) @@ -85,7 +85,7 @@ class HassEventLoopPolicy(PolicyBase): finally: thread.join() - loop.shutdown_default_executor = shutdown_default_executor + setattr(loop, "shutdown_default_executor", shutdown_default_executor) return loop diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 3395baa3e86..578cd33b097 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -22,11 +22,7 @@ DATA_SETUP = "setup_tasks" DATA_DEPS_REQS = "deps_reqs_processed" SLOW_SETUP_WARNING = 10 - -# Since its possible for databases to be -# upwards of 36GiB (or larger) in the wild -# we wait up to 3 hours for startup -SLOW_SETUP_MAX_WAIT = 10800 +SLOW_SETUP_MAX_WAIT = 300 @core.callback @@ -89,7 +85,8 @@ async def _async_process_dependencies( return True _LOGGER.debug("Dependency %s will wait for %s", integration.domain, list(tasks)) - results = await asyncio.gather(*tasks.values()) + async with hass.timeout.async_freeze(integration.domain): + results = await asyncio.gather(*tasks.values()) failed = [ domain @@ -190,7 +187,8 @@ async def _async_setup_component( hass.data[DATA_SETUP_STARTED].pop(domain) return False - result = await asyncio.wait_for(task, SLOW_SETUP_MAX_WAIT) + async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, domain): + result = await task except asyncio.TimeoutError: _LOGGER.error( "Setup of %s is taking longer than %s seconds." @@ -319,9 +317,10 @@ async def async_process_deps_reqs( raise HomeAssistantError("Could not set up all dependencies.") if not hass.config.skip_pip and integration.requirements: - await requirements.async_get_integration_with_requirements( - hass, integration.domain - ) + async with hass.timeout.async_freeze(integration.domain): + await requirements.async_get_integration_with_requirements( + hass, integration.domain + ) processed.add(integration.domain) diff --git a/homeassistant/util/process.py b/homeassistant/util/process.py new file mode 100644 index 00000000000..fb2d6dec58e --- /dev/null +++ b/homeassistant/util/process.py @@ -0,0 +1,12 @@ +"""Util to handle processes.""" + +import subprocess + + +def kill_subprocess(process: subprocess.Popen) -> None: + """Force kill a subprocess and wait for it to exit.""" + process.kill() + process.communicate() + process.wait() + + del process diff --git a/homeassistant/util/timeout.py b/homeassistant/util/timeout.py new file mode 100644 index 00000000000..908d36a41bb --- /dev/null +++ b/homeassistant/util/timeout.py @@ -0,0 +1,508 @@ +"""Advanced timeout handling. + +Set of helper classes to handle timeouts of tasks with advanced options +like zones and freezing of timeouts. +""" +from __future__ import annotations + +import asyncio +import enum +import logging +from types import TracebackType +from typing import Any, Dict, List, Optional, Type, Union + +from .async_ import run_callback_threadsafe + +ZONE_GLOBAL = "global" + +_LOGGER = logging.getLogger(__name__) + + +class _State(str, enum.Enum): + """States of a task.""" + + INIT = "INIT" + ACTIVE = "ACTIVE" + TIMEOUT = "TIMEOUT" + EXIT = "EXIT" + + +class _GlobalFreezeContext: + """Context manager that freezes the global timeout.""" + + def __init__(self, manager: TimeoutManager) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._manager: TimeoutManager = manager + + async def __aenter__(self) -> _GlobalFreezeContext: + self._enter() + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._exit() + return None + + def __enter__(self) -> _GlobalFreezeContext: + self._loop.call_soon_threadsafe(self._enter) + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._loop.call_soon_threadsafe(self._exit) + return True + + def _enter(self) -> None: + """Run freeze.""" + if not self._manager.freezes_done: + return + + # Global reset + for task in self._manager.global_tasks: + task.pause() + + # Zones reset + for zone in self._manager.zones.values(): + if not zone.freezes_done: + continue + zone.pause() + + self._manager.global_freezes.append(self) + + def _exit(self) -> None: + """Finish freeze.""" + self._manager.global_freezes.remove(self) + if not self._manager.freezes_done: + return + + # Global reset + for task in self._manager.global_tasks: + task.reset() + + # Zones reset + for zone in self._manager.zones.values(): + if not zone.freezes_done: + continue + zone.reset() + + +class _ZoneFreezeContext: + """Context manager that freezes a zone timeout.""" + + def __init__(self, zone: _ZoneTimeoutManager) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zone: _ZoneTimeoutManager = zone + + async def __aenter__(self) -> _ZoneFreezeContext: + self._enter() + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._exit() + return None + + def __enter__(self) -> _ZoneFreezeContext: + self._loop.call_soon_threadsafe(self._enter) + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._loop.call_soon_threadsafe(self._exit) + return True + + def _enter(self) -> None: + """Run freeze.""" + if self._zone.freezes_done: + self._zone.pause() + self._zone.enter_freeze(self) + + def _exit(self) -> None: + """Finish freeze.""" + self._zone.exit_freeze(self) + if not self._zone.freezes_done: + return + self._zone.reset() + + +class _GlobalTaskContext: + """Context manager that tracks a global task.""" + + def __init__( + self, + manager: TimeoutManager, + task: asyncio.Task[Any], + timeout: float, + cool_down: float, + ) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._manager: TimeoutManager = manager + self._task: asyncio.Task[Any] = task + self._time_left: float = timeout + self._expiration_time: Optional[float] = None + self._timeout_handler: Optional[asyncio.Handle] = None + self._wait_zone: asyncio.Event = asyncio.Event() + self._state: _State = _State.INIT + self._cool_down: float = cool_down + + async def __aenter__(self) -> _GlobalTaskContext: + self._manager.global_tasks.append(self) + self._start_timer() + self._state = _State.ACTIVE + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._stop_timer() + self._manager.global_tasks.remove(self) + + # Timeout on exit + if exc_type is asyncio.CancelledError and self.state == _State.TIMEOUT: + raise asyncio.TimeoutError + + self._state = _State.EXIT + self._wait_zone.set() + return None + + @property + def state(self) -> _State: + """Return state of the Global task.""" + return self._state + + def zones_done_signal(self) -> None: + """Signal that all zones are done.""" + self._wait_zone.set() + + def _start_timer(self) -> None: + """Start timeout handler.""" + if self._timeout_handler: + return + + self._expiration_time = self._loop.time() + self._time_left + self._timeout_handler = self._loop.call_at( + self._expiration_time, self._on_timeout + ) + + def _stop_timer(self) -> None: + """Stop zone timer.""" + if self._timeout_handler is None: + return + + self._timeout_handler.cancel() + self._timeout_handler = None + # Calculate new timeout + assert self._expiration_time + self._time_left = self._expiration_time - self._loop.time() + + def _on_timeout(self) -> None: + """Process timeout.""" + self._state = _State.TIMEOUT + self._timeout_handler = None + + # Reset timer if zones are running + if not self._manager.zones_done: + asyncio.create_task(self._on_wait()) + else: + self._cancel_task() + + def _cancel_task(self) -> None: + """Cancel own task.""" + if self._task.done(): + return + self._task.cancel() + + def pause(self) -> None: + """Pause timers while it freeze.""" + self._stop_timer() + + def reset(self) -> None: + """Reset timer after freeze.""" + self._start_timer() + + async def _on_wait(self) -> None: + """Wait until zones are done.""" + await self._wait_zone.wait() + await asyncio.sleep(self._cool_down) # Allow context switch + if not self.state == _State.TIMEOUT: + return + self._cancel_task() + + +class _ZoneTaskContext: + """Context manager that tracks an active task for a zone.""" + + def __init__( + self, zone: _ZoneTimeoutManager, task: asyncio.Task[Any], timeout: float, + ) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zone: _ZoneTimeoutManager = zone + self._task: asyncio.Task[Any] = task + self._state: _State = _State.INIT + self._time_left: float = timeout + self._expiration_time: Optional[float] = None + self._timeout_handler: Optional[asyncio.Handle] = None + + @property + def state(self) -> _State: + """Return state of the Zone task.""" + return self._state + + async def __aenter__(self) -> _ZoneTaskContext: + self._zone.enter_task(self) + self._state = _State.ACTIVE + + # Zone is on freeze + if self._zone.freezes_done: + self._start_timer() + + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._zone.exit_task(self) + self._stop_timer() + + # Timeout on exit + if exc_type is asyncio.CancelledError and self.state == _State.TIMEOUT: + raise asyncio.TimeoutError + + self._state = _State.EXIT + return None + + def _start_timer(self) -> None: + """Start timeout handler.""" + if self._timeout_handler: + return + + self._expiration_time = self._loop.time() + self._time_left + self._timeout_handler = self._loop.call_at( + self._expiration_time, self._on_timeout + ) + + def _stop_timer(self) -> None: + """Stop zone timer.""" + if self._timeout_handler is None: + return + + self._timeout_handler.cancel() + self._timeout_handler = None + # Calculate new timeout + assert self._expiration_time + self._time_left = self._expiration_time - self._loop.time() + + def _on_timeout(self) -> None: + """Process timeout.""" + self._state = _State.TIMEOUT + self._timeout_handler = None + + # Timeout + if self._task.done(): + return + self._task.cancel() + + def pause(self) -> None: + """Pause timers while it freeze.""" + self._stop_timer() + + def reset(self) -> None: + """Reset timer after freeze.""" + self._start_timer() + + +class _ZoneTimeoutManager: + """Manage the timeouts for a zone.""" + + def __init__(self, manager: TimeoutManager, zone: str) -> None: + """Initialize internal timeout context manager.""" + self._manager: TimeoutManager = manager + self._zone: str = zone + self._tasks: List[_ZoneTaskContext] = [] + self._freezes: List[_ZoneFreezeContext] = [] + + @property + def name(self) -> str: + """Return Zone name.""" + return self._zone + + @property + def active(self) -> bool: + """Return True if zone is active.""" + return len(self._tasks) > 0 or len(self._freezes) > 0 + + @property + def freezes_done(self) -> bool: + """Return True if all freeze are done.""" + return len(self._freezes) == 0 and self._manager.freezes_done + + def enter_task(self, task: _ZoneTaskContext) -> None: + """Start into new Task.""" + self._tasks.append(task) + + def exit_task(self, task: _ZoneTaskContext) -> None: + """Exit a running Task.""" + self._tasks.remove(task) + + # On latest listener + if not self.active: + self._manager.drop_zone(self.name) + + def enter_freeze(self, freeze: _ZoneFreezeContext) -> None: + """Start into new freeze.""" + self._freezes.append(freeze) + + def exit_freeze(self, freeze: _ZoneFreezeContext) -> None: + """Exit a running Freeze.""" + self._freezes.remove(freeze) + + # On latest listener + if not self.active: + self._manager.drop_zone(self.name) + + def pause(self) -> None: + """Stop timers while it freeze.""" + if not self.active: + return + + # Forward pause + for task in self._tasks: + task.pause() + + def reset(self) -> None: + """Reset timer after freeze.""" + if not self.active: + return + + # Forward reset + for task in self._tasks: + task.reset() + + +class TimeoutManager: + """Class to manage timeouts over different zones. + + Manages both global and zone based timeouts. + """ + + def __init__(self) -> None: + """Initialize TimeoutManager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zones: Dict[str, _ZoneTimeoutManager] = {} + self._globals: List[_GlobalTaskContext] = [] + self._freezes: List[_GlobalFreezeContext] = [] + + @property + def zones_done(self) -> bool: + """Return True if all zones are finished.""" + return not bool(self._zones) + + @property + def freezes_done(self) -> bool: + """Return True if all freezes are finished.""" + return not self._freezes + + @property + def zones(self) -> Dict[str, _ZoneTimeoutManager]: + """Return all Zones.""" + return self._zones + + @property + def global_tasks(self) -> List[_GlobalTaskContext]: + """Return all global Tasks.""" + return self._globals + + @property + def global_freezes(self) -> List[_GlobalFreezeContext]: + """Return all global Freezes.""" + return self._freezes + + def drop_zone(self, zone_name: str) -> None: + """Drop a zone out of scope.""" + self._zones.pop(zone_name, None) + if self._zones: + return + + # Signal Global task, all zones are done + for task in self._globals: + task.zones_done_signal() + + def async_timeout( + self, timeout: float, zone_name: str = ZONE_GLOBAL, cool_down: float = 0 + ) -> Union[_ZoneTaskContext, _GlobalTaskContext]: + """Timeout based on a zone. + + For using as Async Context Manager. + """ + current_task: Optional[asyncio.Task[Any]] = asyncio.current_task() + assert current_task + + # Global Zone + if zone_name == ZONE_GLOBAL: + task = _GlobalTaskContext(self, current_task, timeout, cool_down) + return task + + # Zone Handling + if zone_name in self.zones: + zone: _ZoneTimeoutManager = self.zones[zone_name] + else: + self.zones[zone_name] = zone = _ZoneTimeoutManager(self, zone_name) + + # Create Task + return _ZoneTaskContext(zone, current_task, timeout) + + def async_freeze( + self, zone_name: str = ZONE_GLOBAL + ) -> Union[_ZoneFreezeContext, _GlobalFreezeContext]: + """Freeze all timer until job is done. + + For using as Async Context Manager. + """ + # Global Freeze + if zone_name == ZONE_GLOBAL: + return _GlobalFreezeContext(self) + + # Zone Freeze + if zone_name in self.zones: + zone: _ZoneTimeoutManager = self.zones[zone_name] + else: + self.zones[zone_name] = zone = _ZoneTimeoutManager(self, zone_name) + + return _ZoneFreezeContext(zone) + + def freeze( + self, zone_name: str = ZONE_GLOBAL + ) -> Union[_ZoneFreezeContext, _GlobalFreezeContext]: + """Freeze all timer until job is done. + + For using as Context Manager. + """ + return run_callback_threadsafe( + self._loop, self.async_freeze, zone_name + ).result() diff --git a/pylintrc b/pylintrc index df53c2f67a2..f2860026cd8 100644 --- a/pylintrc +++ b/pylintrc @@ -5,7 +5,7 @@ ignore=tests jobs=2 load-plugins=pylint_strict_informational persistent=no -extension-pkg-whitelist=ciso8601 +extension-pkg-whitelist=ciso8601,cv2 [BASIC] good-names=id,i,j,k,ex,Run,_,fp,T,ev diff --git a/requirements.txt b/requirements.txt index efae1204e11..702e4eaf19f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.6.1 +aiohttp==3.6.2 astral==1.10.1 async_timeout==3.0.1 attrs==19.3.0 @@ -13,7 +13,7 @@ jinja2>=2.11.1 PyJWT==1.7.1 cryptography==2.9.2 pip>=8.0.3 -python-slugify==4.0.0 +python-slugify==4.0.1 pytz>=2020.1 pyyaml==5.3.1 requests==2.24.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9f074b745c6..45acef0fd07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -18,7 +18,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==2.9.2 +HAP-python==3.0.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -71,7 +71,7 @@ PyTurboJPEG==1.4.0 PyViCare==0.2.0 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.12.4 +PyXiaomiGateway==0.13.2 # homeassistant.components.bmp280 # homeassistant.components.mcp23017 @@ -102,6 +102,9 @@ YesssSMS==0.4.1 # homeassistant.components.abode abodepy==1.1.0 +# homeassistant.components.accuweather +accuweather==0.0.9 + # homeassistant.components.mcp23017 adafruit-blinka==3.9.0 @@ -139,11 +142,14 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.1.1 +aioambient==1.2.1 # homeassistant.components.asuswrt aioasuswrt==1.2.7 +# homeassistant.components.azure_devops +aioazuredevops==1.3.5 + # homeassistant.components.aws aiobotocore==0.11.1 @@ -167,7 +173,7 @@ aioguardian==1.0.1 aioharmony==0.2.6 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.45 +aiohomekit[IP]==0.2.46 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -215,6 +221,9 @@ aioswitcher==1.2.0 # homeassistant.components.unifi aiounifi==23 +# homeassistant.components.yandex_transport +aioymaps==1.0.0 + # homeassistant.components.airly airly==0.0.2 @@ -319,7 +328,7 @@ beautifulsoup4==4.9.0 beewi_smartclim==0.0.7 # homeassistant.components.zha -bellows==0.18.0 +bellows==0.18.1 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.7 @@ -331,7 +340,7 @@ bizkaibus==0.1.1 blebox_uniapi==1.3.2 # homeassistant.components.blink -blinkpy==0.15.1 +blinkpy==0.16.3 # homeassistant.components.blinksticklight blinkstick==1.1.8 @@ -353,7 +362,7 @@ blockchain==1.4.4 bomradarloop==0.1.4 # homeassistant.components.bond -bond-home==0.0.9 +bond-api==0.1.8 # homeassistant.components.amazon_polly # homeassistant.components.route53 @@ -482,7 +491,7 @@ distro==1.5.0 dlipower==0.7.165 # homeassistant.components.doorbird -doorbirdpy==2.0.8 +doorbirdpy==2.1.0 # homeassistant.components.dovado dovado==0.4.1 @@ -515,7 +524,7 @@ elgato==0.2.0 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==0.7.18 +elkm1-lib==0.7.19 # homeassistant.components.mobile_app emoji==0.5.4 @@ -697,7 +706,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.34.7 +hass-nabucasa==0.35.0 # homeassistant.components.jewish_calendar hdate==0.9.5 @@ -721,10 +730,10 @@ hlk-sw16==0.0.8 hole==0.5.1 # homeassistant.components.workday -holidays==0.10.2 +holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200716.0 +home-assistant-frontend==20200811.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -815,7 +824,7 @@ konnected==1.1.0 lakeside==0.12 # homeassistant.components.dyson -libpurecool==0.6.1 +libpurecool==0.6.3 # homeassistant.components.foscam libpyfoscam==1.0 @@ -893,7 +902,7 @@ messagebird==1.2.0 meteoalertapi==0.1.6 # homeassistant.components.meteo_france -meteofrance==0.3.7 +meteofrance-api==0.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -966,13 +975,13 @@ nsw-fuel-api-client==1.0.10 nuheat==0.3.0 # homeassistant.components.numato -numato-gpio==0.7.1 +numato-gpio==0.8.0 # homeassistant.components.iqvia # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.19.0 +numpy==1.19.1 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -993,7 +1002,7 @@ onvif-zeep-async==0.4.0 open-garage==0.1.4 # homeassistant.components.opencv -# opencv-python-headless==4.2.0.32 +# opencv-python-headless==4.3.0.36 # homeassistant.components.openerz openerz-api==0.1.0 @@ -1019,6 +1028,9 @@ oru==0.1.11 # homeassistant.components.orvibo orvibo==1.1.1 +# homeassistant.components.ovo_energy +ovoenergy==1.1.6 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.0 @@ -1108,7 +1120,7 @@ proliphix==0.4.1 prometheus_client==0.7.1 # homeassistant.components.tensorflow -protobuf==3.6.1 +protobuf==3.12.2 # homeassistant.components.proxmoxve proxmoxer==1.1.1 @@ -1161,12 +1173,15 @@ py17track==2.2.2 # homeassistant.components.hdmi_cec pyCEC==0.4.13 +# homeassistant.components.control4 +pyControl4==0.0.6 + # homeassistant.components.tplink pyHS100==0.3.5.1 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.5.1 +pyMetno==0.7.0 # homeassistant.components.rfxtrx pyRFXtrx==0.25 @@ -1193,7 +1208,7 @@ py_nextbusnext==0.1.4 pyads==3.2.1 # homeassistant.components.hisense_aehw4a1 -pyaehw4a1==0.3.5 +pyaehw4a1==0.3.9 # homeassistant.components.aftership pyaftership==0.1.2 @@ -1211,7 +1226,7 @@ pyarlo==0.2.3 pyatag==0.3.3.4 # homeassistant.components.netatmo -pyatmo==3.3.1 +pyatmo==4.0.0 # homeassistant.components.atome pyatome==0.1.1 @@ -1246,6 +1261,9 @@ pychromecast==7.2.0 # homeassistant.components.cmus pycmus==0.1.1 +# homeassistant.components.tensorflow +pycocotools==2.0.1 + # homeassistant.components.comfoconnect pycomfoconnect==0.3 @@ -1322,7 +1340,7 @@ pyflexit==0.3 pyflic-homeassistant==0.4.dev0 # homeassistant.components.flume -pyflume==0.4.0 +pyflume==0.5.5 # homeassistant.components.flunearyou pyflunearyou==1.0.7 @@ -1365,7 +1383,7 @@ pyhik==0.2.7 pyhiveapi==0.2.20.1 # homeassistant.components.homematic -pyhomematic==0.1.67 +pyhomematic==0.1.68 # homeassistant.components.homeworks pyhomeworks==0.0.6 @@ -1424,9 +1442,6 @@ pylgnetcast-homeassistant==0.2.0.dev0 # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 -# homeassistant.components.linky -pylinky==0.4.0 - # homeassistant.components.litejet pylitejet==0.1 @@ -1442,6 +1457,9 @@ pylutron==0.2.5 # homeassistant.components.mailgun pymailgunner==1.4 +# homeassistant.components.firmata +pymata-express==1.13 + # homeassistant.components.mediaroom pymediaroom==0.6.4 @@ -1503,7 +1521,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.obihai -pyobihai==1.2.1 +pyobihai==1.2.3 # homeassistant.components.ombi pyombi==0.1.10 @@ -1559,7 +1577,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==0.4.1 +pyrainbird==0.4.2 # homeassistant.components.recswitch pyrecswitch==1.0.2 @@ -1595,6 +1613,9 @@ pysher==1.0.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.4 +# homeassistant.components.sky_hub +pyskyqhub==0.1.1 + # homeassistant.components.sma pysma==0.3.5 @@ -1605,7 +1626,7 @@ pysmappee==0.1.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.2 +pysmartthings==0.7.3 # homeassistant.components.smarty pysmarty==0.8 @@ -1809,11 +1830,14 @@ pyvizio==0.1.49 # homeassistant.components.velux pyvlx==0.2.16 +# homeassistant.components.volumio +pyvolumio==0.1.1 + # homeassistant.components.html5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.4.43 +pywemo==0.4.45 # homeassistant.components.xeoma pyxeoma==1.4.1 @@ -1852,7 +1876,7 @@ raspyrfm-client==1.2.8 recollect-waste==1.0.1 # homeassistant.components.rainmachine -regenmaschine==1.5.1 +regenmaschine==2.1.0 # homeassistant.components.python_script restrictedpython==5.0 @@ -1939,7 +1963,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==9.2.1 +simplisafe-python==9.2.2 # homeassistant.components.sisyphus sisyphus-control==2.2.1 @@ -2077,13 +2101,19 @@ temescal==0.1 temperusb==1.5.3 # homeassistant.components.tensorflow -# tensorflow==1.13.2 +# tensorflow==2.2.0 # homeassistant.components.powerwall tesla-powerwall==0.2.12 # homeassistant.components.tesla -teslajsonpy==0.9.3 +teslajsonpy==0.10.1 + +# homeassistant.components.tensorflow +# tf-models-official==2.2.1 + +# homeassistant.components.tensorflow +tf-slim==1.1.0 # homeassistant.components.thermoworks_smoke thermoworks_smoke==0.1.8 @@ -2101,7 +2131,7 @@ tmb==0.0.4 todoist-python==8.0.0 # homeassistant.components.toon -toonapi==0.1.0 +toonapi==0.2.0 # homeassistant.components.totalconnect total_connect_client==0.55 @@ -2149,9 +2179,6 @@ vallox-websocket-api==2.4.0 # homeassistant.components.venstar venstarcolortouch==0.12 -# homeassistant.components.meteo_france -vigilancemeteo==3.0.1 - # homeassistant.components.vilfo vilfo-api-client==0.3.2 @@ -2159,7 +2186,7 @@ vilfo-api-client==0.3.2 volkszaehler==0.1.2 # homeassistant.components.volvooncall -volvooncall==0.8.7 +volvooncall==0.8.12 # homeassistant.components.verisure vsure==1.5.4 @@ -2189,10 +2216,10 @@ webexteamssdk==1.1.1 websocket-client==0.54.0 # homeassistant.components.wiffi -wiffi==1.0.0 +wiffi==1.0.1 # homeassistant.components.wirelesstag -wirelesstagpy==0.4.0 +wirelesstagpy==0.4.1 # homeassistant.components.withings withings-api==2.1.6 @@ -2200,6 +2227,9 @@ withings-api==2.1.6 # homeassistant.components.wled wled==0.4.3 +# homeassistant.components.wolflink +wolf_smartset==0.1.4 + # homeassistant.components.xbee xbee-helper==0.0.7 @@ -2223,9 +2253,6 @@ xmltodict==0.12.0 # homeassistant.components.xs1 xs1-api-client==3.0.0 -# homeassistant.components.yandex_transport -ya_ma==0.3.8 - # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.1.6 @@ -2236,16 +2263,16 @@ yeelight==0.5.2 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2020.06.16.1 +youtube_dl==2020.07.28 # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.27.1 +zeroconf==0.28.0 # homeassistant.components.zha -zha-quirks==0.0.42 +zha-quirks==0.0.43 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test.txt b/requirements_test.txt index 44977fd8904..25e3db656da 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ -r requirements_test_pre_commit.txt asynctest==0.13.0 codecov==2.1.0 -coverage==5.2 +coverage==5.2.1 mock-open==1.4.0 mypy==0.780 pre-commit==2.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 359ff4ce433..c1b4f560dc9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -5,7 +5,7 @@ -r requirements_test.txt # homeassistant.components.homekit -HAP-python==2.9.2 +HAP-python==3.0.0 # homeassistant.components.plugwise Plugwise_Smile==1.1.0 @@ -31,7 +31,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.4.0 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.12.4 +PyXiaomiGateway==0.13.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 @@ -45,6 +45,9 @@ YesssSMS==0.4.1 # homeassistant.components.abode abodepy==1.1.0 +# homeassistant.components.accuweather +accuweather==0.0.9 + # homeassistant.components.androidtv adb-shell[async]==0.2.1 @@ -67,11 +70,14 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.1.1 +aioambient==1.2.1 # homeassistant.components.asuswrt aioasuswrt==1.2.7 +# homeassistant.components.azure_devops +aioazuredevops==1.3.5 + # homeassistant.components.aws aiobotocore==0.11.1 @@ -92,7 +98,7 @@ aioguardian==1.0.1 aioharmony==0.2.6 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.45 +aiohomekit[IP]==0.2.46 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -125,6 +131,9 @@ aioswitcher==1.2.0 # homeassistant.components.unifi aiounifi==23 +# homeassistant.components.yandex_transport +aioymaps==1.0.0 + # homeassistant.components.airly airly==0.0.2 @@ -166,19 +175,19 @@ azure-eventhub==5.1.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.18.0 +bellows==0.18.1 # homeassistant.components.blebox blebox_uniapi==1.3.2 # homeassistant.components.blink -blinkpy==0.15.1 +blinkpy==0.16.3 # homeassistant.components.bom bomradarloop==0.1.4 # homeassistant.components.bond -bond-home==0.0.9 +bond-api==0.1.8 # homeassistant.components.braviatv bravia-tv==1.0.6 @@ -243,7 +252,7 @@ directv==0.3.0 distro==1.5.0 # homeassistant.components.doorbird -doorbirdpy==2.0.8 +doorbirdpy==2.1.0 # homeassistant.components.dsmr dsmr_parser==0.18 @@ -258,7 +267,7 @@ eebrightbox==0.0.4 elgato==0.2.0 # homeassistant.components.elkm1 -elkm1-lib==0.7.18 +elkm1-lib==0.7.19 # homeassistant.components.mobile_app emoji==0.5.4 @@ -335,7 +344,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.34.7 +hass-nabucasa==0.35.0 # homeassistant.components.jewish_calendar hdate==0.9.5 @@ -343,14 +352,17 @@ hdate==0.9.5 # homeassistant.components.here_travel_time herepy==2.0.0 +# homeassistant.components.hlk_sw16 +hlk-sw16==0.0.8 + # homeassistant.components.pi_hole hole==0.5.1 # homeassistant.components.workday -holidays==0.10.2 +holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200716.0 +home-assistant-frontend==20200811.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -391,7 +403,7 @@ keyrings.alt==3.4.0 konnected==1.1.0 # homeassistant.components.dyson -libpurecool==0.6.1 +libpurecool==0.6.3 # homeassistant.components.mikrotik librouteros==3.0.0 @@ -412,7 +424,7 @@ mbddns==0.1.2 mcstatus==2.3.0 # homeassistant.components.meteo_france -meteofrance==0.3.7 +meteofrance-api==0.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -443,13 +455,13 @@ nsw-fuel-api-client==1.0.10 nuheat==0.3.0 # homeassistant.components.numato -numato-gpio==0.7.1 +numato-gpio==0.8.0 # homeassistant.components.iqvia # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.19.0 +numpy==1.19.1 # homeassistant.components.google oauth2client==4.0.0 @@ -460,6 +472,9 @@ onvif-zeep-async==0.4.0 # homeassistant.components.openerz openerz-api==0.1.0 +# homeassistant.components.ovo_energy +ovoenergy==1.1.6 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.0 @@ -536,12 +551,15 @@ py-melissa-climate==2.0.0 # homeassistant.components.seventeentrack py17track==2.2.2 +# homeassistant.components.control4 +pyControl4==0.0.6 + # homeassistant.components.tplink pyHS100==0.3.5.1 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.5.1 +pyMetno==0.7.0 # homeassistant.components.rfxtrx pyRFXtrx==0.25 @@ -553,7 +571,7 @@ pyTibber==0.14.0 py_nextbusnext==0.1.4 # homeassistant.components.hisense_aehw4a1 -pyaehw4a1==0.3.5 +pyaehw4a1==0.3.9 # homeassistant.components.airvisual pyairvisual==4.4.0 @@ -568,7 +586,7 @@ pyarlo==0.2.3 pyatag==0.3.3.4 # homeassistant.components.netatmo -pyatmo==3.3.1 +pyatmo==4.0.0 # homeassistant.components.blackbird pyblackbird==0.5 @@ -604,7 +622,7 @@ pyeverlights==0.1.0 pyfido==2.1.1 # homeassistant.components.flume -pyflume==0.4.0 +pyflume==0.5.5 # homeassistant.components.flunearyou pyflunearyou==1.0.7 @@ -632,7 +650,7 @@ pyhaversion==3.3.0 pyheos==0.6.0 # homeassistant.components.homematic -pyhomematic==0.1.67 +pyhomematic==0.1.68 # homeassistant.components.icloud pyicloud==0.9.7 @@ -658,9 +676,6 @@ pylast==3.2.1 # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 -# homeassistant.components.linky -pylinky==0.4.0 - # homeassistant.components.litejet pylitejet==0.1 @@ -670,6 +685,9 @@ pylutron-caseta==0.6.1 # homeassistant.components.mailgun pymailgunner==1.4 +# homeassistant.components.firmata +pymata-express==1.13 + # homeassistant.components.melcloud pymelcloud==2.5.2 @@ -737,7 +755,7 @@ pysmappee==0.1.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.2 +pysmartthings==0.7.3 # homeassistant.components.soma pysoma==0.0.10 @@ -811,6 +829,9 @@ pyvesync==1.1.0 # homeassistant.components.vizio pyvizio==0.1.49 +# homeassistant.components.volumio +pyvolumio==0.1.1 + # homeassistant.components.html5 pywebpush==1.9.2 @@ -821,7 +842,7 @@ pyzerproc==0.2.5 rachiopy==0.1.3 # homeassistant.components.rainmachine -regenmaschine==1.5.1 +regenmaschine==2.1.0 # homeassistant.components.python_script restrictedpython==5.0 @@ -857,7 +878,7 @@ sentry-sdk==0.13.5 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==9.2.1 +simplisafe-python==9.2.2 # homeassistant.components.sleepiq sleepyq==0.7 @@ -883,6 +904,9 @@ speak2mary==1.4.0 # homeassistant.components.speedtestdotnet speedtest-cli==2.1.2 +# homeassistant.components.spider +spiderpy==1.3.1 + # homeassistant.components.spotify spotipy==2.12.0 @@ -912,10 +936,10 @@ tellduslive==0.10.11 tesla-powerwall==0.2.12 # homeassistant.components.tesla -teslajsonpy==0.9.3 +teslajsonpy==0.10.1 # homeassistant.components.toon -toonapi==0.1.0 +toonapi==0.2.0 # homeassistant.components.totalconnect total_connect_client==0.55 @@ -942,9 +966,6 @@ url-normalize==1.4.1 # homeassistant.components.uvc uvcclient==0.11.0 -# homeassistant.components.meteo_france -vigilancemeteo==3.0.1 - # homeassistant.components.vilfo vilfo-api-client==0.3.2 @@ -961,7 +982,7 @@ wakeonlan==1.1.6 watchdog==0.8.3 # homeassistant.components.wiffi -wiffi==1.0.0 +wiffi==1.0.1 # homeassistant.components.withings withings-api==2.1.6 @@ -969,6 +990,9 @@ withings-api==2.1.6 # homeassistant.components.wled wled==0.4.3 +# homeassistant.components.wolflink +wolf_smartset==0.1.4 + # homeassistant.components.bluesound # homeassistant.components.rest # homeassistant.components.startca @@ -977,14 +1001,14 @@ wled==0.4.3 # homeassistant.components.zestimate xmltodict==0.12.0 -# homeassistant.components.yandex_transport -ya_ma==0.3.8 +# homeassistant.components.yeelight +yeelight==0.5.2 # homeassistant.components.zeroconf -zeroconf==0.27.1 +zeroconf==0.28.0 # homeassistant.components.zha -zha-quirks==0.0.42 +zha-quirks==0.0.43 # homeassistant.components.zha zigpy-cc==0.4.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4625924da29..772b9af5034 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -41,6 +41,7 @@ COMMENT_REQUIREMENTS = ( "RPi.GPIO", "smbus-cffi", "tensorflow", + "tf-models-official", "VL53L1X2", ) diff --git a/script/run-in-env.sh b/script/run-in-env.sh index d9fe17f4b17..0f531f235b6 100755 --- a/script/run-in-env.sh +++ b/script/run-in-env.sh @@ -1,4 +1,5 @@ -#!/usr/bin/env sh -eu +#!/usr/bin/env sh +set -eu # Activate pyenv and virtualenv if present, then run the specified command @@ -9,10 +10,12 @@ if [ -s .python-version ]; then fi # other common virtualenvs +my_path=$(git rev-parse --show-toplevel) + for venv in venv .venv .; do - if [ -f $venv/bin/activate ]; then - . $venv/bin/activate - fi + if [ -f "${my_path}/${venv}/bin/activate" ]; then + . "${my_path}/${venv}/bin/activate" + fi done exec "$@" diff --git a/script/setup b/script/setup index eb7bda18d44..83c2d24f038 100755 --- a/script/setup +++ b/script/setup @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setups the repository. # Stop on errors @@ -14,7 +14,7 @@ source venv/bin/activate script/bootstrap pre-commit install -pip install -e . --constraint homeassistant/package_constraints.txt +python3 -m pip install -e . --constraint homeassistant/package_constraints.txt hass --script ensure_config -c config diff --git a/setup.cfg b/setup.cfg index 7df396df528..6dace4932db 100644 --- a/setup.cfg +++ b/setup.cfg @@ -64,7 +64,7 @@ warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true -[mypy-homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*] +[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*] strict = true ignore_errors = false warn_unreachable = true diff --git a/setup.py b/setup.py index 7cf06942f32..81f8727ed60 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ PROJECT_URLS = { PACKAGES = find_packages(exclude=["tests", "tests.*"]) REQUIRES = [ - "aiohttp==3.6.1", + "aiohttp==3.6.2", "astral==1.10.1", "async_timeout==3.0.1", "attrs==19.3.0", @@ -45,7 +45,7 @@ REQUIRES = [ # PyJWT has loose dependency. We want the latest one. "cryptography==2.9.2", "pip>=8.0.3", - "python-slugify==4.0.0", + "python-slugify==4.0.1", "pytz>=2020.1", "pyyaml==5.3.1", "requests==2.24.0", diff --git a/tests/common.py b/tests/common.py index 1436b0f5a8a..bcb66428f6b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -285,7 +285,7 @@ fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message) @ha.callback -def async_fire_time_changed(hass, datetime_): +def async_fire_time_changed(hass, datetime_, fire_all=False): """Fire a time changes event.""" hass.bus.async_fire(EVENT_TIME_CHANGED, {"now": date_util.as_utc(datetime_)}) @@ -298,9 +298,13 @@ def async_fire_time_changed(hass, datetime_): future_seconds = task.when() - hass.loop.time() mock_seconds_into_future = datetime_.timestamp() - time.time() - if mock_seconds_into_future >= future_seconds: - task._run() - task.cancel() + if fire_all or mock_seconds_into_future >= future_seconds: + with patch( + "homeassistant.helpers.event.pattern_utc_now", + return_value=date_util.as_utc(datetime_), + ): + task._run() + task.cancel() fire_time_changed = threadsafe_callback_factory(async_fire_time_changed) @@ -351,6 +355,7 @@ def mock_registry(hass, mock_entries=None): """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) registry.entities = mock_entries or OrderedDict() + registry._rebuild_index() hass.data[entity_registry.DATA_REGISTRY] = registry return registry @@ -370,6 +375,7 @@ def mock_device_registry(hass, mock_entries=None, mock_deleted_entries=None): registry = device_registry.DeviceRegistry(hass) registry.devices = mock_entries or OrderedDict() registry.deleted_devices = mock_deleted_entries or OrderedDict() + registry._rebuild_index() hass.data[device_registry.DATA_REGISTRY] = registry return registry diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py new file mode 100644 index 00000000000..97ae531ddd0 --- /dev/null +++ b/tests/components/accuweather/__init__.py @@ -0,0 +1 @@ +"""Tests for AccuWeather.""" diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py new file mode 100644 index 00000000000..6b7430e524d --- /dev/null +++ b/tests/components/accuweather/test_config_flow.py @@ -0,0 +1,162 @@ +"""Define tests for the AccuWeather config flow.""" +import json + +from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError + +from homeassistant import data_entry_flow +from homeassistant.components.accuweather.const import CONF_FORECAST, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry, load_fixture + +VALID_CONFIG = { + CONF_NAME: "abcd", + CONF_API_KEY: "32-character-string-1234567890qw", + CONF_LATITUDE: 55.55, + CONF_LONGITUDE: 122.12, +} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + +async def test_api_key_too_short(hass): + """Test that errors are shown when API key is too short.""" + # The API key length check is done by the library without polling the AccuWeather + # server so we don't need to patch the library method. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_NAME: "abcd", + CONF_API_KEY: "foo", + CONF_LATITUDE: 55.55, + CONF_LONGITUDE: 122.12, + }, + ) + + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_invalid_api_key(hass): + """Test that errors are shown when API key is invalid.""" + with patch( + "accuweather.AccuWeather._async_get_data", + side_effect=InvalidApiKeyError("Invalid API key"), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_api_error(hass): + """Test API error.""" + with patch( + "accuweather.AccuWeather._async_get_data", + side_effect=ApiError("Invalid response from AccuWeather API"), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_requests_exceeded_error(hass): + """Test requests exceeded error.""" + with patch( + "accuweather.AccuWeather._async_get_data", + side_effect=RequestsExceededError( + "The allowed number of requests has been exceeded" + ), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["errors"] == {CONF_API_KEY: "requests_exceeded"} + + +async def test_integration_already_exists(hass): + """Test we only allow a single config flow.""" + with patch( + "accuweather.AccuWeather._async_get_data", + return_value=json.loads(load_fixture("accuweather/location_data.json")), + ): + MockConfigEntry( + domain=DOMAIN, unique_id="123456", data=VALID_CONFIG, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_create_entry(hass): + """Test that the user step works.""" + with patch( + "accuweather.AccuWeather._async_get_data", + return_value=json.loads(load_fixture("accuweather/location_data.json")), + ), patch( + "homeassistant.components.accuweather.async_setup_entry", return_value=True + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "abcd" + assert result["data"][CONF_NAME] == "abcd" + assert result["data"][CONF_LATITUDE] == 55.55 + assert result["data"][CONF_LONGITUDE] == 122.12 + assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" + + +async def test_options_flow(hass): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="123456", data=VALID_CONFIG, + ) + config_entry.add_to_hass(hass) + + with patch( + "accuweather.AccuWeather._async_get_data", + return_value=json.loads(load_fixture("accuweather/location_data.json")), + ), patch( + "accuweather.AccuWeather.async_get_current_conditions", + return_value=json.loads( + load_fixture("accuweather/current_conditions_data.json") + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_FORECAST: True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_FORECAST: True} diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index f31dfb7712d..29828bddc17 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -1 +1,32 @@ """Tests for Airly.""" +import json + +from homeassistant.components.airly.const import DOMAIN + +from tests.async_mock import patch +from tests.common import MockConfigEntry, load_fixture + + +async def init_integration(hass, forecast=False) -> MockConfigEntry: + """Set up the Airly integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="55.55-122.12", + data={ + "api_key": "foo", + "latitude": 55.55, + "longitude": 122.12, + "name": "Home", + }, + ) + + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/airly/test_air_quality.py b/tests/components/airly/test_air_quality.py new file mode 100644 index 00000000000..fca2761f2f3 --- /dev/null +++ b/tests/components/airly/test_air_quality.py @@ -0,0 +1,113 @@ +"""Test air_quality of Airly integration.""" +from datetime import timedelta +import json + +from airly.exceptions import AirlyError + +from homeassistant.components.air_quality import ATTR_AQI, ATTR_PM_2_5, ATTR_PM_10 +from homeassistant.components.airly.air_quality import ( + ATTRIBUTION, + LABEL_ADVICE, + LABEL_AQI_DESCRIPTION, + LABEL_AQI_LEVEL, + LABEL_PM_2_5_LIMIT, + LABEL_PM_2_5_PERCENT, + LABEL_PM_10_LIMIT, + LABEL_PM_10_PERCENT, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + STATE_UNAVAILABLE, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.async_mock import patch +from tests.common import async_fire_time_changed, load_fixture +from tests.components.airly import init_integration + + +async def test_air_quality(hass): + """Test states of the air_quality.""" + await init_integration(hass) + registry = await hass.helpers.entity_registry.async_get_registry() + + state = hass.states.get("air_quality.home") + assert state + assert state.state == "14" + assert state.attributes.get(ATTR_AQI) == 23 + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(LABEL_ADVICE) == "Great air!" + assert state.attributes.get(ATTR_PM_10) == 19 + assert state.attributes.get(ATTR_PM_2_5) == 14 + assert state.attributes.get(LABEL_AQI_DESCRIPTION) == "Great air here today!" + assert state.attributes.get(LABEL_AQI_LEVEL) == "very low" + assert state.attributes.get(LABEL_PM_2_5_LIMIT) == 25.0 + assert state.attributes.get(LABEL_PM_2_5_PERCENT) == 55 + assert state.attributes.get(LABEL_PM_10_LIMIT) == 50.0 + assert state.attributes.get(LABEL_PM_10_PERCENT) == 37 + 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("air_quality.home") + assert entry + assert entry.unique_id == "55.55-122.12" + + +async def test_availability(hass): + """Ensure that we mark the entities unavailable correctly when service causes an error.""" + await init_integration(hass) + + state = hass.states.get("air_quality.home") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "14" + + future = utcnow() + timedelta(minutes=60) + with patch( + "airly._private._RequestsHandler.get", + side_effect=AirlyError(500, "Unexpected error"), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("air_quality.home") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=120) + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("air_quality.home") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "14" + + +async def test_manual_update_entity(hass): + """Test manual update entity via service homeasasistant/update_entity.""" + await init_integration(hass) + + await async_setup_component(hass, "homeassistant", {}) + with patch( + "homeassistant.components.airly.AirlyDataUpdateCoordinator._async_update_data" + ) as mock_update: + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["air_quality.home"]}, + blocking=True, + ) + assert mock_update.call_count == 1 diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py new file mode 100644 index 00000000000..28f2aca4fbb --- /dev/null +++ b/tests/components/airly/test_init.py @@ -0,0 +1,140 @@ +"""Test init of Airly integration.""" +from datetime import timedelta +import json + +from homeassistant.components.airly.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import STATE_UNAVAILABLE + +from tests.async_mock import patch +from tests.common import MockConfigEntry, load_fixture +from tests.components.airly import init_integration + + +async def test_async_setup_entry(hass): + """Test a successful setup entry.""" + await init_integration(hass) + + state = hass.states.get("air_quality.home") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "14" + + +async def test_config_not_ready(hass): + """Test for setup failure if connection to Airly is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="55.55-122.12", + data={ + "api_key": "foo", + "latitude": 55.55, + "longitude": 122.12, + "name": "Home", + }, + ) + + with patch("airly._private._RequestsHandler.get", side_effect=ConnectionError()): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_config_without_unique_id(hass): + """Test for setup entry without unique_id.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + data={ + "api_key": "foo", + "latitude": 55.55, + "longitude": 122.12, + "name": "Home", + }, + ) + + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_LOADED + assert entry.unique_id == "55.55-122.12" + + +async def test_config_with_turned_off_station(hass): + """Test for setup entry for a turned off measuring station.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="55.55-122.12", + data={ + "api_key": "foo", + "latitude": 55.55, + "longitude": 122.12, + "name": "Home", + }, + ) + + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_no_station.json")), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_update_interval(hass): + """Test correct update interval when the number of configured instances changes.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + for instance in hass.data[DOMAIN].values(): + assert instance.update_interval == timedelta(minutes=15) + + entry = MockConfigEntry( + domain=DOMAIN, + title="Work", + unique_id="66.66-111.11", + data={ + "api_key": "foo", + "latitude": 66.66, + "longitude": 111.11, + "name": "Work", + }, + ) + + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert entry.state == ENTRY_STATE_LOADED + for instance in hass.data[DOMAIN].values(): + assert instance.update_interval == timedelta(minutes=30) + + +async def test_unload_entry(hass): + """Test successful unload of entry.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py new file mode 100644 index 00000000000..3131789c6e0 --- /dev/null +++ b/tests/components/airly/test_sensor.py @@ -0,0 +1,128 @@ +"""Test sensor of Airly integration.""" +from datetime import timedelta +import json + +from homeassistant.components.airly.sensor import ATTRIBUTION +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_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PRESSURE_HPA, + STATE_UNAVAILABLE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.async_mock import patch +from tests.common import async_fire_time_changed, load_fixture +from tests.components.airly import init_integration + + +async def test_sensor(hass): + """Test states of the sensor.""" + await init_integration(hass) + registry = await hass.helpers.entity_registry.async_get_registry() + + state = hass.states.get("sensor.home_humidity") + assert state + assert state.state == "92.8" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY + + entry = registry.async_get("sensor.home_humidity") + assert entry + assert entry.unique_id == "55.55-122.12-humidity" + + state = hass.states.get("sensor.home_pm1") + assert state + assert state.state == "9" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + 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.home_pm1") + assert entry + assert entry.unique_id == "55.55-122.12-pm1" + + state = hass.states.get("sensor.home_pressure") + assert state + assert state.state == "1001" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_HPA + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE + + entry = registry.async_get("sensor.home_pressure") + assert entry + assert entry.unique_id == "55.55-122.12-pressure" + + state = hass.states.get("sensor.home_temperature") + assert state + assert state.state == "14.2" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + + entry = registry.async_get("sensor.home_temperature") + assert entry + assert entry.unique_id == "55.55-122.12-temperature" + + +async def test_availability(hass): + """Ensure that we mark the entities unavailable correctly when service is offline.""" + await init_integration(hass) + + state = hass.states.get("sensor.home_humidity") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "92.8" + + future = utcnow() + timedelta(minutes=60) + with patch("airly._private._RequestsHandler.get", side_effect=ConnectionError()): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_humidity") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=120) + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_humidity") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "92.8" + + +async def test_manual_update_entity(hass): + """Test manual update entity via service homeasasistant/update_entity.""" + await init_integration(hass) + + await async_setup_component(hass, "homeassistant", {}) + with patch( + "homeassistant.components.airly.AirlyDataUpdateCoordinator._async_update_data" + ) as mock_update: + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.home_humidity"]}, + blocking=True, + ) + assert mock_update.call_count == 1 diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 72754c3c96f..74c98da3189 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -133,6 +133,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): device_id=device_entry.id, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "arm_away": {"extra_fields": []}, @@ -170,6 +171,7 @@ async def test_get_action_capabilities_arm_code(hass, device_reg, entity_reg): device_id=device_entry.id, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "arm_away": { @@ -267,6 +269,8 @@ async def test_action(hass): }, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + assert ( hass.states.get("alarm_control_panel.alarm_no_arm_code").state == STATE_UNKNOWN ) diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index 6918e47adb3..98c38719cf0 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -141,7 +141,7 @@ PATCH_ANDROIDTV_OPEN = patch( ) PATCH_KEYGEN = patch("homeassistant.components.androidtv.media_player.keygen") PATCH_SIGNER = patch( - "homeassistant.components.androidtv.media_player.PythonRSASigner", + "homeassistant.components.androidtv.media_player.ADBPythonSync.load_adbkey", return_value="signer for testing", ) diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index adece1430ca..bf16957b07f 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -47,6 +47,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PLATFORM, + EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_PLAYING, STATE_STANDBY, @@ -1154,3 +1155,22 @@ async def test_services_firetv(hass): await _test_service(hass, entity_id, SERVICE_MEDIA_STOP, "back") await _test_service(hass, entity_id, SERVICE_TURN_OFF, "adb_shell") await _test_service(hass, entity_id, SERVICE_TURN_ON, "adb_shell") + + +async def test_connection_closed_on_ha_stop(hass): + """Test that the ADB socket connection is closed when HA stops.""" + patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: + assert await async_setup_component( + hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER + ) + await hass.async_block_till_done() + + with patch( + "androidtv.androidtv.androidtv_async.AndroidTVAsync.adb_close" + ) as adb_close: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert adb_close.called diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index bdae9a1f326..a832b26d752 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -390,6 +390,17 @@ async def test_services(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 + await common.async_toggle(hass, entity_id) + await hass.async_block_till_done() + + assert not automation.is_on(hass, entity_id) + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + + await common.async_toggle(hass, entity_id) + await hass.async_block_till_done() + await common.async_trigger(hass, entity_id) await hass.async_block_till_done() assert len(calls) == 3 @@ -556,9 +567,9 @@ async def test_reload_config_handles_load_fails(hass, calls): assert len(calls) == 2 -@pytest.mark.parametrize("service", ["turn_off", "reload"]) +@pytest.mark.parametrize("service", ["turn_off_stop", "turn_off_no_stop", "reload"]) async def test_automation_stops(hass, calls, service): - """Test that turning off / reloading an automation stops any running actions.""" + """Test that turning off / reloading stops any running actions as appropriate.""" entity_id = "automation.hello" test_entity = "test.entity" @@ -587,13 +598,20 @@ async def test_automation_stops(hass, calls, service): hass.bus.async_fire("test_event") await running.wait() - if service == "turn_off": + if service == "turn_off_stop": await hass.services.async_call( automation.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + elif service == "turn_off_no_stop": + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id, automation.CONF_STOP_ACTIONS: False}, + blocking=True, + ) else: with patch( "homeassistant.config.load_yaml_config_file", @@ -605,7 +623,7 @@ async def test_automation_stops(hass, calls, service): hass.states.async_set(test_entity, "goodbye") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(calls) == (1 if service == "turn_off_no_stop" else 0) async def test_automation_restore_state(hass): diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 13165da8488..9842818efab 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -519,28 +519,69 @@ async def test_if_fires_on_entity_change_with_for(hass, calls): assert 1 == len(calls) -async def test_if_fires_on_entity_removal(hass, calls): - """Test for firing on entity removal, when new_state is None.""" - context = Context() - hass.states.async_set("test.entity", "hello") - await hass.async_block_till_done() - +async def test_if_fires_on_entity_creation_and_removal(hass, calls): + """Test for firing on entity creation and removal, with to/from constraints.""" + # set automations for multiple combinations to/from assert await async_setup_component( hass, automation.DOMAIN, { - automation.DOMAIN: { - "trigger": {"platform": "state", "entity_id": "test.entity"}, - "action": {"service": "test.automation"}, - } + automation.DOMAIN: [ + { + "trigger": {"platform": "state", "entity_id": "test.entity_0"}, + "action": {"service": "test.automation"}, + }, + { + "trigger": { + "platform": "state", + "from": "hello", + "entity_id": "test.entity_1", + }, + "action": {"service": "test.automation"}, + }, + { + "trigger": { + "platform": "state", + "to": "world", + "entity_id": "test.entity_2", + }, + "action": {"service": "test.automation"}, + }, + ], }, ) await hass.async_block_till_done() - assert hass.states.async_remove("test.entity", context=context) + # use contexts to identify trigger entities + context_0 = Context() + context_1 = Context() + context_2 = Context() + + # automation with match_all triggers on creation + hass.states.async_set("test.entity_0", "any", context=context_0) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].context.parent_id == context.id + assert calls[0].context.parent_id == context_0.id + + # create entities, trigger on test.entity_2 ('to' matches, no 'from') + hass.states.async_set("test.entity_1", "hello", context=context_1) + hass.states.async_set("test.entity_2", "world", context=context_2) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].context.parent_id == context_2.id + + # removal of both, trigger on test.entity_1 ('from' matches, no 'to') + assert hass.states.async_remove("test.entity_1", context=context_1) + assert hass.states.async_remove("test.entity_2", context=context_2) + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].context.parent_id == context_1.id + + # automation with match_all triggers on removal + assert hass.states.async_remove("test.entity_0", context=context_0) + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].context.parent_id == context_0.id async def test_if_fires_on_for_condition(hass, calls): diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 0ba85467fcd..c8b95985636 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -4,10 +4,11 @@ from datetime import timedelta import pytest import homeassistant.components.automation as automation +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.async_mock import patch +from tests.async_mock import Mock, patch from tests.common import ( assert_setup_component, async_fire_time_changed, @@ -30,51 +31,122 @@ def setup_comp(hass): async def test_if_fires_using_at(hass, calls): """Test for firing at.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "5:00:00"}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" - }, - }, - } - }, + now = dt_util.utcnow() + + time_that_will_not_match_right_away = now.replace( + year=now.year + 1, hour=4, minute=59, second=0 ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=5, minute=0, second=0)) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" + }, + }, + } + }, + ) + + now = dt_util.utcnow() + + async_fire_time_changed( + hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0) + ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == "time - 5" +async def test_if_fires_using_multiple_at(hass, calls): + """Test for firing at.""" + + now = dt_util.utcnow() + + time_that_will_not_match_right_away = now.replace( + year=now.year + 1, hour=4, minute=59, second=0 + ) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" + }, + }, + } + }, + ) + + now = dt_util.utcnow() + + async_fire_time_changed( + hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0) + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "time - 5" + + async_fire_time_changed( + hass, now.replace(year=now.year + 1, hour=6, minute=0, second=0) + ) + + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "time - 6" + + async def test_if_not_fires_using_wrong_at(hass, calls): """YAML translates time values to total seconds. This should break the before rule. """ - with assert_setup_component(0, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time", - "at": 3605, - # Total seconds. Hour = 3600 second - }, - "action": {"service": "test.automation"}, - } - }, - ) + now = dt_util.utcnow() - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=0, second=5)) + time_that_will_not_match_right_away = now.replace( + year=now.year + 1, hour=1, minute=0, second=0 + ) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + with assert_setup_component(0, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": 3605, + # Total seconds. Hour = 3600 second + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_time_changed( + hass, now.replace(year=now.year + 1, hour=1, minute=0, second=5) + ) await hass.async_block_till_done() assert len(calls) == 0 @@ -207,3 +279,35 @@ async def test_if_action_list_weekday(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 + + +async def test_untrack_time_change(hass): + """Test for removing tracked time changes.""" + mock_track_time_change = Mock() + with patch( + "homeassistant.components.automation.time.async_track_time_change", + return_value=mock_track_time_change, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": { + "platform": "time", + "at": ["5:00:00", "6:00:00", "7:00:00"], + }, + "action": {"service": "test.automation", "data": {"test": "test"}}, + } + }, + ) + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "automation.test"}, + blocking=True, + ) + + assert len(mock_track_time_change.mock_calls) == 3 diff --git a/tests/components/automation/test_time_pattern.py b/tests/components/automation/test_time_pattern.py index 01aa32f318f..b5141f088e4 100644 --- a/tests/components/automation/test_time_pattern.py +++ b/tests/components/automation/test_time_pattern.py @@ -1,4 +1,5 @@ """The tests for the time_pattern automation.""" +from asynctest.mock import patch import pytest import homeassistant.components.automation as automation @@ -23,53 +24,67 @@ def setup_comp(hass): async def test_if_fires_when_hour_matches(hass, calls): """Test for firing if hour is matching.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 0, - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, hour=3 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 0, + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0)) await hass.async_block_till_done() assert len(calls) == 1 await common.async_turn_off(hass) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 1, hour=0)) await hass.async_block_till_done() assert len(calls) == 1 async def test_if_fires_when_minute_matches(hass, calls): """Test for firing if minutes are matching.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": 0, - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, minute=30 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": 0, + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(minute=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0)) await hass.async_block_till_done() assert len(calls) == 1 @@ -77,23 +92,30 @@ async def test_if_fires_when_minute_matches(hass, calls): async def test_if_fires_when_second_matches(hass, calls): """Test for firing if seconds are matching.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": 0, - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, second=30 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": 0, + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(second=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0)) await hass.async_block_till_done() assert len(calls) == 1 @@ -101,23 +123,32 @@ async def test_if_fires_when_second_matches(hass, calls): async def test_if_fires_when_all_matches(hass, calls): """Test for firing if everything matches.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 1, - "minutes": 2, - "seconds": 3, - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, hour=4 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 1, + "minutes": 2, + "seconds": 3, + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=3)) + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3) + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -125,47 +156,66 @@ async def test_if_fires_when_all_matches(hass, calls): async def test_if_fires_periodic_seconds(hass, calls): """Test for firing periodically every second.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "/2", - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, second=1 + ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "/10", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10) ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0, minute=0, second=2)) - await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) >= 1 async def test_if_fires_periodic_minutes(hass, calls): """Test for firing periodically every minute.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "/2", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0, minute=2, second=0)) + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, minute=1 + ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "/2", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0) + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -173,23 +223,32 @@ async def test_if_fires_periodic_minutes(hass, calls): async def test_if_fires_periodic_hours(hass, calls): """Test for firing periodically every hour.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "/2", - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, hour=1 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "/2", + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=2, minute=0, second=0)) + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0) + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -197,28 +256,41 @@ async def test_if_fires_periodic_hours(hass, calls): async def test_default_values(hass, calls): """Test for firing at 2 minutes every hour.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time_pattern", "minutes": "2"}, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, minute=1 + ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time_pattern", "minutes": "2"}, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0) ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=0)) + await hass.async_block_till_done() + assert len(calls) == 1 + + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=1, minute=2, second=1) + ) await hass.async_block_till_done() assert len(calls) == 1 - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=1)) - - await hass.async_block_till_done() - assert len(calls) == 1 - - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=2, minute=2, second=0)) + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=2, minute=2, second=0) + ) await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/components/azure_devops/__init__.py b/tests/components/azure_devops/__init__.py new file mode 100644 index 00000000000..da15bc6723d --- /dev/null +++ b/tests/components/azure_devops/__init__.py @@ -0,0 +1 @@ +"""Tests for the Azure DevOps integration.""" diff --git a/tests/components/azure_devops/test_config_flow.py b/tests/components/azure_devops/test_config_flow.py new file mode 100644 index 00000000000..b89c9cb69aa --- /dev/null +++ b/tests/components/azure_devops/test_config_flow.py @@ -0,0 +1,257 @@ +"""Test the Azure DevOps config flow.""" +from aioazuredevops.core import DevOpsProject +import aiohttp + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +FIXTURE_REAUTH_INPUT = {CONF_PAT: "abc123"} +FIXTURE_USER_INPUT = {CONF_ORG: "random", CONF_PROJECT: "project", CONF_PAT: "abc123"} + +UNIQUE_ID = "random_project" + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps authorization error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + 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" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_reauth_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps authorization error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + side_effect=aiohttp.ClientError, + ): + 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" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_reauth_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_project_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=None, + ): + 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" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "project_error"} + + +async def test_reauth_project_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps project error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "project_error"} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth works.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=UNIQUE_ID, data=FIXTURE_USER_INPUT + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "authorization_error"} + + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=DevOpsProject( + "abcd-abcd-abcd-abcd", FIXTURE_USER_INPUT[CONF_PROJECT] + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_full_flow_implementation(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow works.""" + with patch( + "homeassistant.components.azure_devops.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.azure_devops.async_setup_entry", return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=DevOpsProject( + "abcd-abcd-abcd-abcd", FIXTURE_USER_INPUT[CONF_PROJECT] + ), + ): + 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" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result2["title"] + == f"{FIXTURE_USER_INPUT[CONF_ORG]}/{FIXTURE_USER_INPUT[CONF_PROJECT]}" + ) + assert result2["data"][CONF_ORG] == FIXTURE_USER_INPUT[CONF_ORG] + assert result2["data"][CONF_PROJECT] == FIXTURE_USER_INPUT[CONF_PROJECT] diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index fef12c88f49..9fd50277d23 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -60,6 +60,7 @@ async def test_get_conditions(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_conditions = [ { diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 92fbce27bc1..cea0103acdb 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -60,6 +60,7 @@ async def test_get_triggers(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_triggers = [ { diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index e6315aac972..99b20d9a73c 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -1,4 +1,7 @@ """Test the Blink config flow.""" +from blinkpy.auth import LoginError +from blinkpy.blinkpy import BlinkSetupError + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.blink import DOMAIN @@ -15,13 +18,9 @@ async def test_form(hass): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.blink.config_flow.Blink", - return_value=Mock( - get_auth_token=Mock(return_value=True), - key_required=False, - login_response={}, - ), + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, ), patch( "homeassistant.components.blink.async_setup", return_value=True ) as mock_setup, patch( @@ -37,48 +36,18 @@ async def test_form(hass): assert result2["data"] == { "username": "blink@example.com", "password": "example", - "login_response": {}, + "device_id": "Home Assistant", + "token": None, + "host": None, + "account_id": None, + "client_id": None, + "region_id": None, } await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_import(hass): - """Test we import the config.""" - with patch( - "homeassistant.components.blink.config_flow.Blink", - return_value=Mock( - get_auth_token=Mock(return_value=True), - key_required=False, - login_response={}, - ), - ), patch( - "homeassistant.components.blink.async_setup_entry", return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": "blink@example.com", - "password": "example", - "scan_interval": 10, - }, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "blink" - assert result["result"].unique_id == "blink@example.com" - assert result["data"] == { - "username": "blink@example.com", - "password": "example", - "scan_interval": 10, - "login_response": {}, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_form_2fa(hass): """Test we get the 2fa form.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -86,28 +55,28 @@ async def test_form_2fa(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_blink = Mock( - get_auth_token=Mock(return_value=True), - key_required=True, - login_response={}, - login_handler=Mock(send_auth_key=Mock(return_value=True)), - ) - - with patch( - "homeassistant.components.blink.config_flow.Blink", return_value=mock_blink + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=True, ), patch( "homeassistant.components.blink.async_setup", return_value=True ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": "blink@example.com", "password": "example"} + result["flow_id"], {"username": "blink@example.com", "password": "example"}, ) assert result2["type"] == "form" assert result2["step_id"] == "2fa" - mock_blink.key_required = False - with patch( - "homeassistant.components.blink.config_flow.Blink", return_value=mock_blink + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Auth.send_auth_key", + return_value=True, + ), patch( + "homeassistant.components.blink.config_flow.Blink.setup_urls", + return_value=True, ), patch( "homeassistant.components.blink.async_setup", return_value=True ) as mock_setup, patch( @@ -125,6 +94,126 @@ async def test_form_2fa(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_2fa_connect_error(hass): + """Test we report a connect error during 2fa setup.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=True, + ), patch("homeassistant.components.blink.async_setup", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": "blink@example.com", "password": "example"}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "2fa" + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Auth.send_auth_key", + return_value=True, + ), patch( + "homeassistant.components.blink.config_flow.Blink.setup_urls", + side_effect=BlinkSetupError, + ), patch( + "homeassistant.components.blink.async_setup", return_value=True + ), patch( + "homeassistant.components.blink.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"pin": "1234"} + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} + + +async def test_form_2fa_invalid_key(hass): + """Test we report an error if key is invalid.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=True, + ), patch("homeassistant.components.blink.async_setup", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": "blink@example.com", "password": "example"}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "2fa" + + with patch("homeassistant.components.blink.config_flow.Auth.startup",), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Auth.send_auth_key", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Blink.setup_urls", + return_value=True, + ), patch( + "homeassistant.components.blink.async_setup", return_value=True + ), patch( + "homeassistant.components.blink.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"pin": "1234"} + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "invalid_access_token"} + + +async def test_form_2fa_unknown_error(hass): + """Test we report an unknown error during 2fa setup.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=True, + ), patch("homeassistant.components.blink.async_setup", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": "blink@example.com", "password": "example"}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "2fa" + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Auth.send_auth_key", + return_value=True, + ), patch( + "homeassistant.components.blink.config_flow.Blink.setup_urls", + side_effect=KeyError, + ), patch( + "homeassistant.components.blink.async_setup", return_value=True + ), patch( + "homeassistant.components.blink.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"pin": "1234"} + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "unknown"} + + async def test_form_invalid_auth(hass): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -132,8 +221,8 @@ async def test_form_invalid_auth(hass): ) with patch( - "homeassistant.components.blink.config_flow.Blink.get_auth_token", - return_value=None, + "homeassistant.components.blink.config_flow.Auth.startup", + side_effect=LoginError, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "blink@example.com", "password": "example"} @@ -150,11 +239,7 @@ async def test_form_unknown_error(hass): ) with patch( - "homeassistant.components.blink.config_flow.Blink.get_auth_token", - return_value=None, - ), patch( - "homeassistant.components.blink.config_flow.validate_input", - side_effect=KeyError, + "homeassistant.components.blink.config_flow.Auth.startup", side_effect=KeyError, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "blink@example.com", "password": "example"} @@ -164,27 +249,34 @@ async def test_form_unknown_error(hass): assert result2["errors"] == {"base": "unknown"} +async def test_reauth_shows_user_step(hass): + """Test reauth shows the user form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + async def test_options_flow(hass): """Test config flow options.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={ - "username": "blink@example.com", - "password": "example", - "login_response": {}, - }, + data={"username": "blink@example.com", "password": "example"}, options={}, entry_id=1, + version=2, ) config_entry.add_to_hass(hass) - mock_blink = Mock( - login_handler=True, - setup_params=Mock(return_value=True), - setup_post_verify=Mock(return_value=True), + mock_auth = Mock( + startup=Mock(return_value=True), check_key_required=Mock(return_value=False) ) + mock_blink = Mock() - with patch("homeassistant.components.blink.Blink", return_value=mock_blink): + with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( + "homeassistant.components.blink.Blink", return_value=mock_blink + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 2a3f727f8bd..31308746555 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -1,28 +1,46 @@ """Common methods used across tests for Bond.""" -from typing import Any, Dict +from asyncio import TimeoutError as AsyncIOTimeoutError +from contextlib import nullcontext +from datetime import timedelta +from typing import Any, Dict, Optional from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, STATE_UNAVAILABLE from homeassistant.setup import async_setup_component +from homeassistant.util import utcnow from tests.async_mock import patch -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -MOCK_HUB_VERSION: dict = {"bondid": "test-bond-id"} + +def patch_setup_entry(domain: str, *, enabled: bool = True): + """Patch async_setup_entry for specified domain.""" + if not enabled: + return nullcontext() + + return patch(f"homeassistant.components.bond.{domain}.async_setup_entry") async def setup_bond_entity( - hass: core.HomeAssistant, config_entry: MockConfigEntry, hub_version=None + hass: core.HomeAssistant, + config_entry: MockConfigEntry, + *, + patch_version=False, + patch_device_ids=False, + patch_platforms=False, ): """Set up Bond entity.""" - if hub_version is None: - hub_version = MOCK_HUB_VERSION - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.bond.Bond.getVersion", return_value=hub_version + with patch_bond_version(enabled=patch_version), patch_bond_device_ids( + enabled=patch_device_ids + ), patch_setup_entry("cover", enabled=patch_platforms), patch_setup_entry( + "fan", enabled=patch_platforms + ), patch_setup_entry( + "light", enabled=patch_platforms + ), patch_setup_entry( + "switch", enabled=patch_platforms ): return await hass.config_entries.async_setup(config_entry.entry_id) @@ -31,32 +49,116 @@ async def setup_platform( hass: core.HomeAssistant, platform: str, discovered_device: Dict[str, Any], + *, bond_device_id: str = "bond-device-id", + bond_version: Dict[str, Any] = None, props: Dict[str, Any] = None, + state: Dict[str, Any] = None, ): """Set up the specified Bond platform.""" - if not props: - props = {} - mock_entry = MockConfigEntry( domain=BOND_DOMAIN, - data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) mock_entry.add_to_hass(hass) - with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch( - "homeassistant.components.bond.Bond.getVersion", return_value=MOCK_HUB_VERSION - ), patch( - "homeassistant.components.bond.Bond.getDeviceIds", - return_value=[bond_device_id], - ), patch( - "homeassistant.components.bond.Bond.getDevice", return_value=discovered_device - ), patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={} - ), patch( - "homeassistant.components.bond.Bond.getProperties", return_value=props - ): - assert await async_setup_component(hass, BOND_DOMAIN, {}) - await hass.async_block_till_done() + with patch("homeassistant.components.bond.PLATFORMS", [platform]): + with patch_bond_version(return_value=bond_version), patch_bond_device_ids( + return_value=[bond_device_id] + ), patch_bond_device( + return_value=discovered_device + ), patch_bond_device_properties( + return_value=props + ), patch_bond_device_state( + return_value=state + ): + assert await async_setup_component(hass, BOND_DOMAIN, {}) + await hass.async_block_till_done() return mock_entry + + +def patch_bond_version( + enabled: bool = True, return_value: Optional[dict] = None, side_effect=None +): + """Patch Bond API version endpoint.""" + if not enabled: + return nullcontext() + + if return_value is None: + return_value = {"bondid": "test-bond-id"} + + return patch( + "homeassistant.components.bond.Bond.version", + return_value=return_value, + side_effect=side_effect, + ) + + +def patch_bond_device_ids(enabled: bool = True, return_value=None, side_effect=None): + """Patch Bond API devices endpoint.""" + if not enabled: + return nullcontext() + + if return_value is None: + return_value = [] + + return patch( + "homeassistant.components.bond.Bond.devices", + return_value=return_value, + side_effect=side_effect, + ) + + +def patch_bond_device(return_value=None): + """Patch Bond API device endpoint.""" + return patch( + "homeassistant.components.bond.Bond.device", return_value=return_value, + ) + + +def patch_bond_action(): + """Patch Bond API action endpoint.""" + return patch("homeassistant.components.bond.Bond.action") + + +def patch_bond_device_properties(return_value=None): + """Patch Bond API device properties endpoint.""" + if return_value is None: + return_value = {} + + return patch( + "homeassistant.components.bond.Bond.device_properties", + return_value=return_value, + ) + + +def patch_bond_device_state(return_value=None, side_effect=None): + """Patch Bond API device state endpoint.""" + if return_value is None: + return_value = {} + + return patch( + "homeassistant.components.bond.Bond.device_state", + return_value=return_value, + side_effect=side_effect, + ) + + +async def help_test_entity_available( + hass: core.HomeAssistant, domain: str, device: Dict[str, Any], entity_id: str +): + """Run common test to verify available property.""" + await setup_platform(hass, domain, device) + + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + with patch_bond_device_state(side_effect=AsyncIOTimeoutError()): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + with patch_bond_device_state(return_value={}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 94b98b45d6f..bd499b8ce61 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -1,17 +1,20 @@ """Test the Bond config flow.""" -from json import JSONDecodeError +from typing import Any, Dict -from requests.exceptions import ConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from homeassistant import config_entries, core, setup from homeassistant.components.bond.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from tests.async_mock import patch +from .common import patch_bond_device_ids, patch_bond_version + +from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry -async def test_form(hass: core.HomeAssistant): - """Test we get the form.""" +async def test_user_form(hass: core.HomeAssistant): + """Test we get the user initiated form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -19,21 +22,18 @@ async def test_form(hass: core.HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.bond.config_flow.Bond.getDeviceIds", return_value=[], - ), patch( - "homeassistant.components.bond.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.bond.async_setup_entry", return_value=True, - ) as mock_setup_entry: + with patch_bond_version( + return_value={"bondid": "test-bond-id"} + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) assert result2["type"] == "create_entry" - assert result2["title"] == "1.1.1.1" + assert result2["title"] == "test-bond-id" assert result2["data"] == { - CONF_HOST: "1.1.1.1", + CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", } await hass.async_block_till_done() @@ -41,55 +41,194 @@ async def test_form(hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: core.HomeAssistant): +async def test_user_form_invalid_auth(hass: core.HomeAssistant): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.bond.config_flow.Bond.getDeviceIds", - side_effect=JSONDecodeError("test-message", "test-doc", 0), + with patch_bond_version( + return_value={"bond_id": "test-bond-id"} + ), patch_bond_device_ids( + side_effect=ClientResponseError(Mock(), Mock(), status=401), ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: core.HomeAssistant): +async def test_user_form_cannot_connect(hass: core.HomeAssistant): """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.bond.config_flow.Bond.getDeviceIds", - side_effect=ConnectionError, - ): + with patch_bond_version( + side_effect=ClientConnectionError() + ), patch_bond_device_ids(): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected_error(hass: core.HomeAssistant): +async def test_user_form_unexpected_client_error(hass: core.HomeAssistant): + """Test we handle unexpected client error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_USER, + user_input={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + error=ClientResponseError(Mock(), Mock(), status=500), + ) + + +async def test_user_form_unexpected_error(hass: core.HomeAssistant): """Test we handle unexpected error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_USER, + user_input={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + error=Exception(), + ) + + +async def test_user_form_one_entry_per_device_allowed(hass: core.HomeAssistant): + """Test that only one entry allowed per unique ID reported by Bond hub device.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="already-registered-bond-id", + data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ).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_USER} ) - with patch( - "homeassistant.components.bond.config_flow.Bond.getDeviceIds", - side_effect=Exception, - ): + with patch_bond_version( + return_value={"bondid": "already-registered-bond-id"} + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_form(hass: core.HomeAssistant): + """Test we get the discovery form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={"name": "test-bond-id.some-other-tail-info", "host": "test-host"}, + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch_bond_version( + return_value={"bondid": "test-bond-id"} + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-bond-id" + assert result2["data"] == { + CONF_HOST: "test-host", + CONF_ACCESS_TOKEN: "test-token", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_already_configured(hass: core.HomeAssistant): + """Test starting a flow from discovery when already configured.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="already-registered-bond-id", + data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "test-token"}, + ) + entry.add_to_hass(hass) + + with _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "name": "already-registered-bond-id.some-other-tail-info", + "host": "updated-host", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data["host"] == "updated-host" + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_form_unexpected_error(hass: core.HomeAssistant): + """Test we handle unexpected error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_ZEROCONF, + initial_input={ + "name": "test-bond-id.some-other-tail-info", + "host": "test-host", + }, + user_input={CONF_ACCESS_TOKEN: "test-token"}, + error=Exception(), + ) + + +async def _help_test_form_unexpected_error( + hass: core.HomeAssistant, + *, + source: str, + initial_input: Dict[str, Any] = None, + user_input: Dict[str, Any], + error: Exception, +): + """Test we handle unexpected error gracefully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=initial_input + ) + + with patch_bond_version( + return_value={"bond_id": "test-bond-id"} + ), patch_bond_device_ids(side_effect=error): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input ) assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} + + +def _patch_async_setup(): + return patch("homeassistant.components.bond.async_setup", return_value=True) + + +def _patch_async_setup_entry(): + return patch("homeassistant.components.bond.async_setup_entry", return_value=True,) diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index 9e1f87e8c0d..a9083a900e6 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from bond import DeviceTypes +from bond_api import Action, DeviceType from homeassistant import core from homeassistant.components.cover import DOMAIN as COVER_DOMAIN @@ -15,9 +15,13 @@ from homeassistant.const import ( from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import setup_platform +from .common import ( + help_test_entity_available, + patch_bond_action, + patch_bond_device_state, + setup_platform, +) -from tests.async_mock import patch from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) @@ -25,22 +29,31 @@ _LOGGER = logging.getLogger(__name__) def shades(name: str): """Create motorized shades with given name.""" - return {"name": name, "type": DeviceTypes.MOTORIZED_SHADES} + return {"name": name, "type": DeviceType.MOTORIZED_SHADES} async def test_entity_registry(hass: core.HomeAssistant): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, COVER_DOMAIN, shades("name-1")) + await setup_platform( + hass, + COVER_DOMAIN, + shades("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() - assert [key for key in registry.entities] == ["cover.name_1"] + entity = registry.entities["cover.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" async def test_open_cover(hass: core.HomeAssistant): """Tests that open cover command delegates to API.""" - await setup_platform(hass, COVER_DOMAIN, shades("name-1")) + await setup_platform( + hass, COVER_DOMAIN, shades("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.open") as mock_open: + with patch_bond_action() as mock_open, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -48,14 +61,17 @@ async def test_open_cover(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_open.assert_called_once() + + mock_open.assert_called_once_with("test-device-id", Action.open()) async def test_close_cover(hass: core.HomeAssistant): """Tests that close cover command delegates to API.""" - await setup_platform(hass, COVER_DOMAIN, shades("name-1")) + await setup_platform( + hass, COVER_DOMAIN, shades("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.close") as mock_close: + with patch_bond_action() as mock_close, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, @@ -63,14 +79,17 @@ async def test_close_cover(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_close.assert_called_once() + + mock_close.assert_called_once_with("test-device-id", Action.close()) async def test_stop_cover(hass: core.HomeAssistant): """Tests that stop cover command delegates to API.""" - await setup_platform(hass, COVER_DOMAIN, shades("name-1")) + await setup_platform( + hass, COVER_DOMAIN, shades("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.hold") as mock_hold: + with patch_bond_action() as mock_hold, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, @@ -78,16 +97,15 @@ async def test_stop_cover(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_hold.assert_called_once() + + mock_hold.assert_called_once_with("test-device-id", Action.hold()) async def test_update_reports_open_cover(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports cover is open.""" await setup_platform(hass, COVER_DOMAIN, shades("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"open": 1} - ): + with patch_bond_device_state(return_value={"open": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -98,10 +116,15 @@ async def test_update_reports_closed_cover(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports cover is closed.""" await setup_platform(hass, COVER_DOMAIN, shades("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"open": 0} - ): + with patch_bond_device_state(return_value={"open": 0}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert hass.states.get("cover.name_1").state == "closed" + + +async def test_cover_available(hass: core.HomeAssistant): + """Tests that available state is updated based on API errors.""" + await help_test_entity_available( + hass, COVER_DOMAIN, shades("name-1"), "cover.name_1" + ) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 0c2df04e2a9..fa7e59e30e6 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -1,7 +1,8 @@ """Tests for the Bond fan device.""" from datetime import timedelta +from typing import Optional -from bond import DeviceTypes, Directions +from bond_api import Action, DeviceType, Direction from homeassistant import core from homeassistant.components import fan @@ -17,9 +18,13 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import setup_platform +from .common import ( + help_test_entity_available, + patch_bond_action, + patch_bond_device_state, + setup_platform, +) -from tests.async_mock import patch from tests.common import async_fire_time_changed @@ -27,31 +32,40 @@ def ceiling_fan(name: str): """Create a ceiling fan with given name.""" return { "name": name, - "type": DeviceTypes.CEILING_FAN, + "type": DeviceType.CEILING_FAN, "actions": ["SetSpeed", "SetDirection"], } -async def turn_fan_on(hass: core.HomeAssistant, fan_id: str, speed: str) -> None: +async def turn_fan_on( + hass: core.HomeAssistant, fan_id: str, speed: Optional[str] = None +) -> None: """Turn the fan on at the specified speed.""" + service_data = {ATTR_ENTITY_ID: fan_id} + if speed: + service_data[fan.ATTR_SPEED] = speed await hass.services.async_call( - FAN_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: fan_id, fan.ATTR_SPEED: speed}, - blocking=True, + FAN_DOMAIN, SERVICE_TURN_ON, service_data=service_data, blocking=True, ) await hass.async_block_till_done() async def test_entity_registry(hass: core.HomeAssistant): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() - assert [key for key in registry.entities] == ["fan.name_1"] + entity = registry.entities["fan.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" -async def test_entity_non_standard_speed_list(hass: core.HomeAssistant): +async def test_non_standard_speed_list(hass: core.HomeAssistant): """Tests that the device is registered with custom speed list if number of supported speeds differs form 3.""" await setup_platform( hass, @@ -69,59 +83,84 @@ async def test_entity_non_standard_speed_list(hass: core.HomeAssistant): fan.SPEED_HIGH, ] - with patch("homeassistant.components.bond.Bond.turnOn"), patch( - "homeassistant.components.bond.Bond.setSpeed" - ) as mock_set_speed_low: - await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) - mock_set_speed_low.assert_called_once_with("test-device-id", speed=1) + with patch_bond_device_state(): + with patch_bond_action() as mock_set_speed_low: + await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) + mock_set_speed_low.assert_called_once_with( + "test-device-id", Action.set_speed(1) + ) - with patch("homeassistant.components.bond.Bond.turnOn"), patch( - "homeassistant.components.bond.Bond.setSpeed" - ) as mock_set_speed_medium: - await turn_fan_on(hass, "fan.name_1", fan.SPEED_MEDIUM) - mock_set_speed_medium.assert_called_once_with("test-device-id", speed=3) + with patch_bond_action() as mock_set_speed_medium: + await turn_fan_on(hass, "fan.name_1", fan.SPEED_MEDIUM) + mock_set_speed_medium.assert_called_once_with( + "test-device-id", Action.set_speed(3) + ) - with patch("homeassistant.components.bond.Bond.turnOn"), patch( - "homeassistant.components.bond.Bond.setSpeed" - ) as mock_set_speed_high: - await turn_fan_on(hass, "fan.name_1", fan.SPEED_HIGH) - mock_set_speed_high.assert_called_once_with("test-device-id", speed=6) + with patch_bond_action() as mock_set_speed_high: + await turn_fan_on(hass, "fan.name_1", fan.SPEED_HIGH) + mock_set_speed_high.assert_called_once_with( + "test-device-id", Action.set_speed(6) + ) -async def test_turn_on_fan(hass: core.HomeAssistant): - """Tests that turn on command delegates to API.""" - await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) +async def test_fan_speed_with_no_max_seed(hass: core.HomeAssistant): + """Tests that fans without max speed (increase/decrease controls) map speed to HA standard.""" + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_device_id="test-device-id", + props={"no": "max_speed"}, + state={"power": 1, "speed": 14}, + ) - with patch("homeassistant.components.bond.Bond.turnOn") as mock_turn_on, patch( - "homeassistant.components.bond.Bond.setSpeed" - ) as mock_set_speed: + assert hass.states.get("fan.name_1").attributes["speed"] == fan.SPEED_HIGH + + +async def test_turn_on_fan_with_speed(hass: core.HomeAssistant): + """Tests that turn on command delegates to set speed API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_set_speed, patch_bond_device_state(): await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) - mock_set_speed.assert_called_once() - mock_turn_on.assert_called_once() + mock_set_speed.assert_called_with("test-device-id", Action.set_speed(1)) + + +async def test_turn_on_fan_without_speed(hass: core.HomeAssistant): + """Tests that turn on command delegates to turn on API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_turn_on, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1") + + mock_turn_on.assert_called_with("test-device-id", Action.turn_on()) async def test_turn_off_fan(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" - await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.turnOff") as mock_turn_off: + with patch_bond_action() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "fan.name_1"}, blocking=True, ) await hass.async_block_till_done() - mock_turn_off.assert_called_once() + mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) async def test_update_reports_fan_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports fan power is on.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"power": 1, "speed": 1}, - ): + with patch_bond_device_state(return_value={"power": 1, "speed": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -132,10 +171,7 @@ async def test_update_reports_fan_off(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports fan power is off.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"power": 0, "speed": 1}, - ): + with patch_bond_device_state(return_value={"power": 0, "speed": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -146,10 +182,7 @@ async def test_update_reports_direction_forward(hass: core.HomeAssistant): """Tests that update command sets correct direction when Bond API reports fan direction is forward.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"direction": Directions.FORWARD}, - ): + with patch_bond_device_state(return_value={"direction": Direction.FORWARD}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -160,10 +193,7 @@ async def test_update_reports_direction_reverse(hass: core.HomeAssistant): """Tests that update command sets correct direction when Bond API reports fan direction is reverse.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"direction": Directions.REVERSE}, - ): + with patch_bond_device_state(return_value={"direction": Direction.REVERSE}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -172,9 +202,11 @@ async def test_update_reports_direction_reverse(hass: core.HomeAssistant): async def test_set_fan_direction(hass: core.HomeAssistant): """Tests that set direction command delegates to API.""" - await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.setDirection") as mock_set_direction: + with patch_bond_action() as mock_set_direction, patch_bond_device_state(): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_DIRECTION, @@ -182,4 +214,14 @@ async def test_set_fan_direction(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_set_direction.assert_called_once() + + mock_set_direction.assert_called_once_with( + "test-device-id", Action.set_direction(Direction.FORWARD) + ) + + +async def test_fan_available(hass: core.HomeAssistant): + """Tests that available state is updated based on API errors.""" + await help_test_entity_available( + hass, FAN_DOMAIN, ceiling_fan("name-1"), "fan.name_1" + ) diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 23c11199879..7a0f057f17b 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -1,14 +1,19 @@ """Tests for the Bond module.""" +from aiohttp import ClientConnectionError + from homeassistant.components.bond.const import DOMAIN -from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .common import setup_bond_entity +from .common import patch_bond_version, patch_setup_entry, setup_bond_entity -from tests.async_mock import patch from tests.common import MockConfigEntry @@ -19,35 +24,47 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant): assert result is True +async def test_async_setup_raises_entry_not_ready(hass: HomeAssistant): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + config_entry.add_to_hass(hass) + + with patch_bond_version(side_effect=ClientConnectionError()): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ENTRY_STATE_SETUP_RETRY + + async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAssistant): """Test that configuring entry sets up cover domain.""" config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch( - "homeassistant.components.bond.cover.async_setup_entry" - ) as mock_cover_async_setup_entry, patch( - "homeassistant.components.bond.fan.async_setup_entry" - ) as mock_fan_async_setup_entry, patch( - "homeassistant.components.bond.light.async_setup_entry" - ) as mock_light_async_setup_entry, patch( - "homeassistant.components.bond.switch.async_setup_entry" - ) as mock_switch_async_setup_entry: - result = await setup_bond_entity( - hass, - config_entry, - hub_version={ - "bondid": "test-bond-id", - "target": "test-model", - "fw_ver": "test-version", - }, - ) - assert result is True - await hass.async_block_till_done() + with patch_bond_version( + return_value={ + "bondid": "test-bond-id", + "target": "test-model", + "fw_ver": "test-version", + } + ): + with patch_setup_entry( + "cover" + ) as mock_cover_async_setup_entry, patch_setup_entry( + "fan" + ) as mock_fan_async_setup_entry, patch_setup_entry( + "light" + ) as mock_light_async_setup_entry, patch_setup_entry( + "switch" + ) as mock_switch_async_setup_entry: + result = await setup_bond_entity(hass, config_entry, patch_device_ids=True) + assert result is True + await hass.async_block_till_done() assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state == ENTRY_STATE_LOADED + assert config_entry.unique_id == "test-bond-id" # verify hub device is registered correctly device_registry = await dr.async_get_registry(hass) @@ -69,15 +86,18 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss async def test_unload_config_entry(hass: HomeAssistant): """Test that configuration entry supports unloading.""" config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch("homeassistant.components.bond.cover.async_setup_entry"), patch( - "homeassistant.components.bond.fan.async_setup_entry" - ): - result = await setup_bond_entity(hass, config_entry) - assert result is True - await hass.async_block_till_done() + result = await setup_bond_entity( + hass, + config_entry, + patch_version=True, + patch_device_ids=True, + patch_platforms=True, + ) + assert result is True + await hass.async_block_till_done() await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index fc9edf64727..57ea859240a 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -2,17 +2,31 @@ from datetime import timedelta import logging -from bond import Actions, DeviceTypes +from bond_api import Action, DeviceType from homeassistant import core -from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + DOMAIN as LIGHT_DOMAIN, + SUPPORT_BRIGHTNESS, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import setup_platform +from .common import ( + help_test_entity_available, + patch_bond_action, + patch_bond_device_state, + setup_platform, +) -from tests.async_mock import patch from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) @@ -22,29 +36,88 @@ def ceiling_fan(name: str): """Create a ceiling fan (that has built-in light) with given name.""" return { "name": name, - "type": DeviceTypes.CEILING_FAN, - "actions": [Actions.TOGGLE_LIGHT], + "type": DeviceType.CEILING_FAN, + "actions": [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF], + } + + +def dimmable_ceiling_fan(name: str): + """Create a ceiling fan (that has built-in light) with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF, Action.SET_BRIGHTNESS], } def fireplace(name: str): """Create a fireplace with given name.""" - return {"name": name, "type": DeviceTypes.FIREPLACE} + return {"name": name, "type": DeviceType.FIREPLACE} async def test_entity_registry(hass: core.HomeAssistant): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, + LIGHT_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() - assert [key for key in registry.entities] == ["light.name_1"] + entity = registry.entities["light.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" + + +async def test_sbb_trust_state(hass: core.HomeAssistant): + """Assumed state should be False if device is a Smart by Bond.""" + version = { + "model": "MR123A", + "bondid": "test-bond-id", + } + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_version=version + ) + + device = hass.states.get("light.name_1") + assert device.attributes.get(ATTR_ASSUMED_STATE) is not True + + +async def test_trust_state_not_specified(hass: core.HomeAssistant): + """Assumed state should be True if Trust State is not specified.""" + await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) + + device = hass.states.get("light.name_1") + assert device.attributes.get(ATTR_ASSUMED_STATE) is True + + +async def test_trust_state(hass: core.HomeAssistant): + """Assumed state should be True if Trust State is False.""" + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), props={"trust_state": False} + ) + + device = hass.states.get("light.name_1") + assert device.attributes.get(ATTR_ASSUMED_STATE) is True + + +async def test_no_trust_state(hass: core.HomeAssistant): + """Assumed state should be False if Trust State is True.""" + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), props={"trust_state": True} + ) + device = hass.states.get("light.name_1") + assert device.attributes.get(ATTR_ASSUMED_STATE) is not True async def test_turn_on_light(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" - await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.turnLightOn") as mock_turn_light_on: + with patch_bond_action() as mock_turn_light_on, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -52,16 +125,17 @@ async def test_turn_on_light(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_light_on.assert_called_once() + + mock_turn_light_on.assert_called_once_with("test-device-id", Action.turn_light_on()) async def test_turn_off_light(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" - await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch( - "homeassistant.components.bond.Bond.turnLightOff" - ) as mock_turn_light_off: + with patch_bond_action() as mock_turn_light_off, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -69,16 +143,63 @@ async def test_turn_off_light(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_light_off.assert_called_once() + + mock_turn_light_off.assert_called_once_with( + "test-device-id", Action.turn_light_off() + ) + + +async def test_brightness_support(hass: core.HomeAssistant): + """Tests that a dimmable light should support the brightness feature.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + dimmable_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + state = hass.states.get("light.name_1") + assert state.attributes[ATTR_SUPPORTED_FEATURES] & SUPPORT_BRIGHTNESS + + +async def test_brightness_not_supported(hass: core.HomeAssistant): + """Tests that a non-dimmable light should not support the brightness feature.""" + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id", + ) + + state = hass.states.get("light.name_1") + assert not state.attributes[ATTR_SUPPORTED_FEATURES] & SUPPORT_BRIGHTNESS + + +async def test_turn_on_light_with_brightness(hass: core.HomeAssistant): + """Tests that turn on command, on a dimmable light, delegates to API and parses brightness.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + dimmable_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_set_brightness, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_set_brightness.assert_called_once_with( + "test-device-id", Action(Action.SET_BRIGHTNESS, 50) + ) async def test_update_reports_light_is_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the light is on.""" await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"light": 1} - ): + with patch_bond_device_state(return_value={"light": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -89,24 +210,20 @@ async def test_update_reports_light_is_off(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the light is off.""" await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"light": 0} - ): + with patch_bond_device_state(return_value={"light": 0}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert hass.states.get("light.name_1").state == "off" -async def test_turn_on_fireplace(hass: core.HomeAssistant): - """Tests that turn on command delegates to API.""" +async def test_turn_on_fireplace_with_brightness(hass: core.HomeAssistant): + """Tests that turn on command delegates to set flame API.""" await setup_platform( hass, LIGHT_DOMAIN, fireplace("name-1"), bond_device_id="test-device-id" ) - with patch("homeassistant.components.bond.Bond.turnOn") as mock_turn_on, patch( - "homeassistant.components.bond.Bond.setFlame" - ) as mock_set_flame: + with patch_bond_action() as mock_set_flame, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -115,15 +232,34 @@ async def test_turn_on_fireplace(hass: core.HomeAssistant): ) await hass.async_block_till_done() - mock_turn_on.assert_called_once() - mock_set_flame.assert_called_once_with("test-device-id", 50) + mock_set_flame.assert_called_once_with("test-device-id", Action.set_flame(50)) + + +async def test_turn_on_fireplace_without_brightness(hass: core.HomeAssistant): + """Tests that turn on command delegates to turn on API.""" + await setup_platform( + hass, LIGHT_DOMAIN, fireplace("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_turn_on, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_turn_on.assert_called_once_with("test-device-id", Action.turn_on()) async def test_turn_off_fireplace(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" - await setup_platform(hass, LIGHT_DOMAIN, fireplace("name-1")) + await setup_platform( + hass, LIGHT_DOMAIN, fireplace("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.turnOff") as mock_turn_off: + with patch_bond_action() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -131,19 +267,34 @@ async def test_turn_off_fireplace(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_off.assert_called_once() + + mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) async def test_flame_converted_to_brightness(hass: core.HomeAssistant): """Tests that reported flame level (0..100) converted to HA brightness (0...255).""" await setup_platform(hass, LIGHT_DOMAIN, fireplace("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"power": 1, "flame": 50}, - ): + with patch_bond_device_state(return_value={"power": 1, "flame": 50}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("light.name_1").attributes[ATTR_BRIGHTNESS] == 128 + + +async def test_light_available(hass: core.HomeAssistant): + """Tests that available state is updated based on API errors.""" + await help_test_entity_available( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), "light.name_1" + ) + + +async def test_parse_brightness(hass: core.HomeAssistant): + """Tests that reported brightness level (0..100) converted to HA brightness (0...255).""" + await setup_platform(hass, LIGHT_DOMAIN, dimmable_ceiling_fan("name-1")) + + with patch_bond_device_state(return_value={"light": 1, "brightness": 50}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - _LOGGER.warning(hass.states.get("light.name_1").attributes) assert hass.states.get("light.name_1").attributes[ATTR_BRIGHTNESS] == 128 diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 121bb505cdb..2755a491a86 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from bond import DeviceTypes +from bond_api import Action, DeviceType from homeassistant import core from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -10,9 +10,13 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import setup_platform +from .common import ( + help_test_entity_available, + patch_bond_action, + patch_bond_device_state, + setup_platform, +) -from tests.async_mock import patch from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) @@ -20,22 +24,31 @@ _LOGGER = logging.getLogger(__name__) def generic_device(name: str): """Create a generic device with given name.""" - return {"name": name, "type": DeviceTypes.GENERIC_DEVICE} + return {"name": name, "type": DeviceType.GENERIC_DEVICE} async def test_entity_registry(hass: core.HomeAssistant): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) + await setup_platform( + hass, + SWITCH_DOMAIN, + generic_device("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() - assert [key for key in registry.entities] == ["switch.name_1"] + entity = registry.entities["switch.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" async def test_turn_on_switch(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" - await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) + await setup_platform( + hass, SWITCH_DOMAIN, generic_device("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.turnOn") as mock_turn_on: + with patch_bond_action() as mock_turn_on, patch_bond_device_state(): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -43,14 +56,17 @@ async def test_turn_on_switch(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_on.assert_called_once() + + mock_turn_on.assert_called_once_with("test-device-id", Action.turn_on()) async def test_turn_off_switch(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" - await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) + await setup_platform( + hass, SWITCH_DOMAIN, generic_device("name-1"), bond_device_id="test-device-id" + ) - with patch("homeassistant.components.bond.Bond.turnOff") as mock_turn_off: + with patch_bond_action() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -58,16 +74,15 @@ async def test_turn_off_switch(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_off.assert_called_once() + + mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) async def test_update_reports_switch_is_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the device is on.""" await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"power": 1} - ): + with patch_bond_device_state(return_value={"power": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -78,10 +93,15 @@ async def test_update_reports_switch_is_off(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the device is off.""" await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"power": 0} - ): + with patch_bond_device_state(return_value={"power": 0}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert hass.states.get("switch.name_1").state == "off" + + +async def test_switch_available(hass: core.HomeAssistant): + """Tests that available state is updated based on API errors.""" + await help_test_entity_available( + hass, SWITCH_DOMAIN, generic_device("name-1"), "switch.name_1" + ) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index c4f45ed9704..cc91e521d68 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -27,6 +27,7 @@ def rs(hass): "command_stop", "command_state", None, + 15, ) @@ -45,7 +46,7 @@ def test_query_state_value(rs): assert "foo bar" == result assert mock_run.call_count == 1 assert mock_run.call_args == mock.call( - "runme", shell=True, # nosec # shell by design + "runme", shell=True, timeout=15 # nosec # shell by design ) diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index ecdb5af91da..8509bc785da 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -4,7 +4,7 @@ import tempfile import unittest import homeassistant.components.notify as notify -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component, setup_component from tests.async_mock import patch from tests.common import assert_setup_component, get_test_home_assistant @@ -93,3 +93,25 @@ class TestCommandLine(unittest.TestCase): "notify", "test", {"message": "error"}, blocking=True ) assert mock_error.call_count == 1 + + +async def test_timeout(hass, caplog): + """Test we do not block forever.""" + assert await async_setup_component( + hass, + notify.DOMAIN, + { + "notify": { + "name": "test", + "platform": "command_line", + "command": "sleep 10000", + "command_timeout": 0.0000001, + } + }, + ) + await hass.async_block_till_done() + assert await hass.services.async_call( + "notify", "test", {"message": "error"}, blocking=True + ) + await hass.async_block_till_done() + assert "Timeout" in caplog.text diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 9d7e46002f6..623269b9c16 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -74,7 +74,7 @@ class TestCommandSensorSensor(unittest.TestCase): """Ensure command with templates and quotes get rendered properly.""" self.hass.states.set("sensor.test_state", "Works 2") with patch( - "homeassistant.components.command_line.sensor.subprocess.check_output", + "homeassistant.components.command_line.subprocess.check_output", return_value=b"Works\n", ) as check_output: data = command_line.CommandSensorData( diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 5c4a1aa336f..a9d9c61444a 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -180,13 +180,14 @@ class TestCommandSwitch(unittest.TestCase): "echo 'off command'", None, None, + 15, ] no_state_device = command_line.CommandSwitch(*init_args) assert no_state_device.assumed_state # Set state command - init_args[-2] = "cat {}" + init_args[-3] = "cat {}" state_device = command_line.CommandSwitch(*init_args) assert not state_device.assumed_state @@ -201,6 +202,7 @@ class TestCommandSwitch(unittest.TestCase): "echo 'off command'", False, None, + 15, ] test_switch = command_line.CommandSwitch(*init_args) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index c2557c83a4a..1f82434c7a6 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -46,6 +46,7 @@ async def test_list_devices(hass, client, registry): { "config_entries": ["1234"], "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], + "identifiers": [["bridgeid", "0123"]], "manufacturer": "manufacturer", "model": "model", "name": None, @@ -58,6 +59,7 @@ async def test_list_devices(hass, client, registry): { "config_entries": ["1234"], "connections": [], + "identifiers": [["bridgeid", "1234"]], "manufacturer": "manufacturer", "model": "model", "name": None, diff --git a/tests/components/control4/__init__.py b/tests/components/control4/__init__.py new file mode 100644 index 00000000000..8995968d5dd --- /dev/null +++ b/tests/components/control4/__init__.py @@ -0,0 +1 @@ +"""Tests for the Control4 integration.""" diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py new file mode 100644 index 00000000000..6d3293b147a --- /dev/null +++ b/tests/components/control4/test_config_flow.py @@ -0,0 +1,198 @@ +"""Test the Control4 config flow.""" +import datetime + +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import Unauthorized + +from homeassistant import config_entries, setup +from homeassistant.components.control4.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + +from tests.async_mock import AsyncMock, patch +from tests.common import MockConfigEntry + + +def _get_mock_c4_account( + getAccountControllers={ + "controllerCommonName": "control4_model_00AA00AA00AA", + "href": "https://apis.control4.com/account/v3/rest/accounts/000000", + "name": "Name", + }, + getDirectorBearerToken={ + "token": "token", + "token_expiration": datetime.datetime(2020, 7, 15, 13, 50, 15, 26940), + }, +): + c4_account_mock = AsyncMock(C4Account) + + c4_account_mock.getAccountControllers.return_value = getAccountControllers + c4_account_mock.getDirectorBearerToken.return_value = getDirectorBearerToken + + return c4_account_mock + + +def _get_mock_c4_director(getAllItemInfo={}): + c4_director_mock = AsyncMock(C4Director) + c4_director_mock.getAllItemInfo.return_value = getAllItemInfo + + return c4_director_mock + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + c4_account = _get_mock_c4_account() + c4_director = _get_mock_c4_director() + with patch( + "homeassistant.components.control4.config_flow.C4Account", + return_value=c4_account, + ), patch( + "homeassistant.components.control4.config_flow.C4Director", + return_value=c4_director, + ), patch( + "homeassistant.components.control4.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.control4.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "control4_model_00AA00AA00AA" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + "controller_unique_id": "control4_model_00AA00AA00AA", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.C4Account", + side_effect=Unauthorized("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_unexpected_exception(hass): + """Test we handle an unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.C4Account", + side_effect=ValueError("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.Control4Validator.authenticate", + return_value=True, + ), patch( + "homeassistant.components.control4.config_flow.C4Director", + side_effect=Unauthorized("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_option_flow(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 4}, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_SCAN_INTERVAL: 4, + } + + +async def test_option_flow_defaults(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + } diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index e70c18621f4..d302353582c 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -46,6 +46,7 @@ async def test_get_actions(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -60,6 +61,12 @@ async def test_get_actions(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": ent.entity_id, }, + { + "domain": DOMAIN, + "type": "stop", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, ] actions = await async_get_device_automations(hass, "action", device_entry.id) assert_lists_same(actions, expected_actions) @@ -81,6 +88,7 @@ async def test_get_actions_tilt(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -95,6 +103,12 @@ async def test_get_actions_tilt(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": ent.entity_id, }, + { + "domain": DOMAIN, + "type": "stop", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, { "domain": DOMAIN, "type": "open_tilt", @@ -128,6 +142,7 @@ async def test_get_actions_set_pos(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -157,6 +172,7 @@ async def test_get_actions_set_tilt_pos(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -171,6 +187,12 @@ async def test_get_actions_set_tilt_pos(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": ent.entity_id, }, + { + "domain": DOMAIN, + "type": "stop", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, { "domain": DOMAIN, "type": "set_tilt_position", @@ -199,9 +221,10 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == 2 # open, close + assert len(actions) == 3 # open, close, stop for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -226,6 +249,7 @@ async def test_get_action_capabilities_set_pos(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "extra_fields": [ @@ -268,6 +292,7 @@ async def test_get_action_capabilities_set_tilt_pos(hass, device_reg, entity_reg ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "extra_fields": [ @@ -282,7 +307,7 @@ async def test_get_action_capabilities_set_tilt_pos(hass, device_reg, entity_reg ] } actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == 3 # open, close, set_tilt_position + assert len(actions) == 4 # open, close, stop, set_tilt_position for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -322,27 +347,41 @@ async def test_action(hass): "type": "close", }, }, + { + "trigger": {"platform": "event", "event_type": "test_event_stop"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "stop", + }, + }, ] }, ) + await hass.async_block_till_done() open_calls = async_mock_service(hass, "cover", "open_cover") close_calls = async_mock_service(hass, "cover", "close_cover") + stop_calls = async_mock_service(hass, "cover", "stop_cover") hass.bus.async_fire("test_event_open") await hass.async_block_till_done() assert len(open_calls) == 1 assert len(close_calls) == 0 + assert len(stop_calls) == 0 hass.bus.async_fire("test_event_close") await hass.async_block_till_done() assert len(open_calls) == 1 assert len(close_calls) == 1 + assert len(stop_calls) == 0 hass.bus.async_fire("test_event_stop") await hass.async_block_till_done() assert len(open_calls) == 1 assert len(close_calls) == 1 + assert len(stop_calls) == 1 async def test_action_tilt(hass): @@ -377,6 +416,7 @@ async def test_action_tilt(hass): ] }, ) + await hass.async_block_till_done() open_calls = async_mock_service(hass, "cover", "open_cover_tilt") close_calls = async_mock_service(hass, "cover", "close_cover_tilt") @@ -437,6 +477,7 @@ async def test_action_set_position(hass): ] }, ) + await hass.async_block_till_done() cover_pos_calls = async_mock_service(hass, "cover", "set_cover_position") tilt_pos_calls = async_mock_service(hass, "cover", "set_cover_tilt_position") diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index 00fb1c1047b..7edf1dc7d60 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -6,6 +6,27 @@ from homeassistant.setup import async_setup_component from tests.async_mock import patch +@pytest.fixture(autouse=True) +def mock_zeroconf(): + """Mock zeroconf.""" + with patch("homeassistant.components.zeroconf.HaZeroconf"): + yield + + +@pytest.fixture(autouse=True) +def mock_ssdp(): + """Mock ssdp.""" + with patch("homeassistant.components.ssdp.Scanner.async_scan"): + yield + + +@pytest.fixture(autouse=True) +def mock_updater(): + """Mock updater.""" + with patch("homeassistant.components.updater.get_newest_version"): + yield + + @pytest.fixture(autouse=True) def recorder_url_mock(): """Mock recorder url.""" diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py index ca9e3f41dbf..0082f9a5439 100644 --- a/tests/components/directv/test_config_flow.py +++ b/tests/components/directv/test_config_flow.py @@ -103,7 +103,7 @@ async def test_user_device_exists_abort( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort user flow if DirecTV receiver already configured.""" - await setup_integration(hass, aioclient_mock) + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -118,7 +118,7 @@ async def test_ssdp_device_exists_abort( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort SSDP flow if DirecTV receiver already configured.""" - await setup_integration(hass, aioclient_mock) + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() result = await hass.config_entries.flow.async_init( @@ -133,7 +133,7 @@ async def test_ssdp_with_receiver_id_device_exists_abort( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort SSDP flow if DirecTV receiver already configured.""" - await setup_integration(hass, aioclient_mock) + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() discovery_info[ATTR_UPNP_SERIAL] = UPNP_SERIAL diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py index da04793e2a9..26f79e3e62f 100644 --- a/tests/components/discovery/test_init.py +++ b/tests/components/discovery/test_init.py @@ -6,6 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.util.dt import utcnow from tests.async_mock import patch @@ -36,10 +37,12 @@ def netdisco_mock(): async def mock_discovery(hass, discoveries, config=BASE_CONFIG): """Mock discoveries.""" - result = await async_setup_component(hass, "discovery", config) - assert result - - await hass.async_start() + with patch("homeassistant.components.zeroconf.async_get_instance"): + assert await async_setup_component(hass, "discovery", config) + await hass.async_block_till_done() + await hass.async_start() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() with patch.object(discovery, "_discover", discoveries), patch( "homeassistant.components.discovery.async_discover", return_value=mock_coro() diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 6e6cc8f55c6..ed660eb4f51 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -366,6 +366,7 @@ async def test_reconnect(hass, monkeypatch, mock_connection_factory): protocol.wait_closed = wait_closed await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() assert connection_factory.call_count == 1 diff --git a/tests/components/elgato/__init__.py b/tests/components/elgato/__init__.py index 75fb5fd0bc8..95791161a1f 100644 --- a/tests/components/elgato/__init__.py +++ b/tests/components/elgato/__init__.py @@ -14,28 +14,34 @@ async def init_integration( """Set up the Elgato Key Light integration in Home Assistant.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", + "http://1.2.3.4:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": "application/json"}, ) aioclient_mock.put( - "http://example.local:9123/elgato/lights", + "http://1.2.3.4:9123/elgato/lights", text=load_fixture("elgato/state.json"), headers={"Content-Type": "application/json"}, ) aioclient_mock.get( - "http://example.local:9123/elgato/lights", + "http://1.2.3.4:9123/elgato/lights", text=load_fixture("elgato/state.json"), headers={"Content-Type": "application/json"}, ) + aioclient_mock.get( + "http://5.6.7.8:9123/elgato/accessory-info", + text=load_fixture("elgato/info.json"), + headers={"Content-Type": "application/json"}, + ) + entry = MockConfigEntry( domain=DOMAIN, unique_id="CN11A1A00001", data={ - CONF_HOST: "example.local", + CONF_HOST: "1.2.3.4", CONF_PORT: 9123, CONF_SERIAL_NUMBER: "CN11A1A00001", }, diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 607db56fed6..7d50aec2e22 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -41,7 +41,7 @@ async def test_show_zerconf_form( ) -> None: """Test that the zeroconf confirmation form is served.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", + "http://1.2.3.4:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": "application/json"}, ) @@ -49,11 +49,9 @@ async def test_show_zerconf_form( flow = config_flow.ElgatoFlowHandler() flow.hass = hass flow.context = {"source": SOURCE_ZEROCONF} - result = await flow.async_step_zeroconf( - {"hostname": "example.local.", "port": 9123} - ) + result = await flow.async_step_zeroconf({"host": "1.2.3.4", "port": 9123}) - assert flow.context[CONF_HOST] == "example.local" + assert flow.context[CONF_HOST] == "1.2.3.4" assert flow.context[CONF_PORT] == 9123 assert flow.context[CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "CN11A1A00001"} @@ -65,14 +63,12 @@ async def test_connection_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we show user form on Elgato Key Light connection error.""" - aioclient_mock.get( - "http://example.local/elgato/accessory-info", exc=aiohttp.ClientError - ) + aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "example.local", CONF_PORT: 9123}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 9123}, ) assert result["errors"] == {"base": "connection_error"} @@ -84,14 +80,12 @@ async def test_zeroconf_connection_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow on Elgato Key Light connection error.""" - aioclient_mock.get( - "http://example.local/elgato/accessory-info", exc=aiohttp.ClientError - ) + aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"hostname": "example.local.", "port": 9123}, + data={"host": "1.2.3.4", "port": 9123}, ) assert result["reason"] == "connection_error" @@ -102,19 +96,17 @@ async def test_zeroconf_confirm_connection_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow on Elgato Key Light connection error.""" - aioclient_mock.get( - "http://example.local/elgato/accessory-info", exc=aiohttp.ClientError - ) + aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) flow = config_flow.ElgatoFlowHandler() flow.hass = hass flow.context = { "source": SOURCE_ZEROCONF, - CONF_HOST: "example.local", + CONF_HOST: "1.2.3.4", CONF_PORT: 9123, } result = await flow.async_step_zeroconf_confirm( - user_input={CONF_HOST: "example.local", CONF_PORT: 9123} + user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 9123} ) assert result["reason"] == "connection_error" @@ -142,7 +134,7 @@ async def test_user_device_exists_abort( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "example.local", CONF_PORT: 9123}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 9123}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -157,7 +149,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"hostname": "example.local.", "port": 9123}, + data={"host": "1.2.3.4", "port": 9123}, ) assert result["reason"] == "already_configured" @@ -165,20 +157,23 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, - context={"source": SOURCE_ZEROCONF, CONF_HOST: "example.local", "port": 9123}, - data={"hostname": "example.local.", "port": 9123}, + context={"source": SOURCE_ZEROCONF, CONF_HOST: "1.2.3.4", "port": 9123}, + data={"host": "5.6.7.8", "port": 9123}, ) assert result["reason"] == "already_configured" assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + entries = hass.config_entries.async_entries(config_flow.DOMAIN) + assert entries[0].data[CONF_HOST] == "5.6.7.8" + async def test_full_user_flow_implementation( hass: HomeAssistant, aioclient_mock ) -> None: """Test the full manual user flow from start to finish.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", + "http://1.2.3.4:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": "application/json"}, ) @@ -191,10 +186,10 @@ async def test_full_user_flow_implementation( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: "example.local", CONF_PORT: 9123} + result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 9123} ) - assert result["data"][CONF_HOST] == "example.local" + assert result["data"][CONF_HOST] == "1.2.3.4" assert result["data"][CONF_PORT] == 9123 assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["title"] == "CN11A1A00001" @@ -209,7 +204,7 @@ async def test_full_zeroconf_flow_implementation( ) -> None: """Test the full manual user flow from start to finish.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", + "http://1.2.3.4:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": "application/json"}, ) @@ -217,21 +212,17 @@ async def test_full_zeroconf_flow_implementation( flow = config_flow.ElgatoFlowHandler() flow.hass = hass flow.context = {"source": SOURCE_ZEROCONF} - result = await flow.async_step_zeroconf( - {"hostname": "example.local.", "port": 9123} - ) + result = await flow.async_step_zeroconf({"host": "1.2.3.4", "port": 9123}) - assert flow.context[CONF_HOST] == "example.local" + assert flow.context[CONF_HOST] == "1.2.3.4" assert flow.context[CONF_PORT] == 9123 assert flow.context[CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "CN11A1A00001"} assert result["step_id"] == "zeroconf_confirm" assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - result = await flow.async_step_zeroconf_confirm( - user_input={CONF_HOST: "example.local"} - ) - assert result["data"][CONF_HOST] == "example.local" + result = await flow.async_step_zeroconf_confirm(user_input={CONF_HOST: "1.2.3.4"}) + assert result["data"][CONF_HOST] == "1.2.3.4" assert result["data"][CONF_PORT] == 9123 assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["title"] == "CN11A1A00001" diff --git a/tests/components/elgato/test_init.py b/tests/components/elgato/test_init.py index fd2f86fe2ea..2f0e39e05a8 100644 --- a/tests/components/elgato/test_init.py +++ b/tests/components/elgato/test_init.py @@ -14,7 +14,7 @@ async def test_config_entry_not_ready( ) -> None: """Test the Elgato Key Light configuration entry not ready.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", exc=aiohttp.ClientError + "http://1.2.3.4:9123/elgato/accessory-info", exc=aiohttp.ClientError ) entry = await init_integration(hass, aioclient_mock) diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 4ffc7cd7f0e..510aa0ef8ee 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1,4 +1,5 @@ """The tests for the emulated Hue component.""" +import asyncio from datetime import timedelta from ipaddress import ip_address import json @@ -18,9 +19,10 @@ from homeassistant.components import ( media_player, script, ) -from homeassistant.components.emulated_hue import Config +from homeassistant.components.emulated_hue import Config, hue_api from homeassistant.components.emulated_hue.hue_api import ( HUE_API_STATE_BRI, + HUE_API_STATE_CT, HUE_API_STATE_HUE, HUE_API_STATE_ON, HUE_API_STATE_SAT, @@ -43,14 +45,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.core import callback import homeassistant.util.dt as dt_util from tests.async_mock import patch -from tests.common import ( - async_fire_time_changed, - async_mock_service, - get_test_instance_port, -) +from tests.common import async_fire_time_changed, get_test_instance_port HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() @@ -77,6 +76,8 @@ ENTITY_IDS_BY_NUMBER = { "16": "humidifier.humidifier", "17": "humidifier.dehumidifier", "18": "humidifier.hygrostat", + "19": "scene.light_on", + "20": "scene.light_off", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} @@ -164,6 +165,28 @@ def hass_hue(loop, hass): ) ) + # setup a dummy scene + loop.run_until_complete( + setup.async_setup_component( + hass, + "scene", + { + "scene": [ + { + "id": "light_on", + "name": "Light on", + "entities": {"light.kitchen_lights": {"state": "on"}}, + }, + { + "id": "light_off", + "name": "Light off", + "entities": {"light.kitchen_lights": {"state": "off"}}, + }, + ] + }, + ) + ) + # create a lamp without brightness support hass.states.async_set("light.no_brightness", "on", {}) @@ -197,6 +220,9 @@ def hue_client(loop, hass_hue, aiohttp_client): "humidifier.dehumidifier": {emulated_hue.CONF_ENTITY_HIDDEN: False}, # No expose setting (use default of not exposed) "climate.nosetting": {}, + # Expose scenes + "scene.light_on": {emulated_hue.CONF_ENTITY_HIDDEN: False}, + "scene.light_off": {emulated_hue.CONF_ENTITY_HIDDEN: False}, }, }, ) @@ -243,6 +269,8 @@ async def test_discover_lights(hue_client): assert "00:78:eb:f8:d5:0c:14:85-e7" in devices # humidifier.humidifier assert "00:67:19:bd:ea:e4:2d:ef-22" in devices # humidifier.dehumidifier assert "00:61:bf:ab:08:b1:a6:18-43" not in devices # humidifier.hygrostat + assert "00:62:5c:3e:df:58:40:01-43" in devices # scene.light_on + assert "00:1c:72:08:ed:09:e7:89-77" in devices # scene.light_off async def test_light_without_brightness_supported(hass_hue, hue_client): @@ -252,15 +280,24 @@ async def test_light_without_brightness_supported(hass_hue, hue_client): ) assert light_without_brightness_json["state"][HUE_API_STATE_ON] is True - assert light_without_brightness_json["type"] == "Dimmable light" + assert light_without_brightness_json["type"] == "On/Off light" async def test_light_without_brightness_can_be_turned_off(hass_hue, hue_client): """Test that light without brightness can be turned off.""" hass_hue.states.async_set("light.no_brightness", "on", {}) + turn_off_calls = [] # Check if light can be turned off - turn_off_calls = async_mock_service(hass_hue, light.DOMAIN, SERVICE_TURN_OFF) + @callback + def mock_service_call(call): + """Mock service call.""" + turn_off_calls.append(call) + hass_hue.states.async_set("light.no_brightness", "off", {}) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_OFF, mock_service_call, schema=None + ) no_brightness_result = await perform_put_light_state( hass_hue, hue_client, "light.no_brightness", False @@ -286,7 +323,17 @@ async def test_light_without_brightness_can_be_turned_on(hass_hue, hue_client): hass_hue.states.async_set("light.no_brightness", "off", {}) # Check if light can be turned on - turn_on_calls = async_mock_service(hass_hue, light.DOMAIN, SERVICE_TURN_ON) + turn_on_calls = [] + + @callback + def mock_service_call(call): + """Mock service call.""" + turn_on_calls.append(call) + hass_hue.states.async_set("light.no_brightness", "on", {}) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_ON, mock_service_call, schema=None + ) no_brightness_result = await perform_put_light_state( hass_hue, @@ -423,7 +470,7 @@ async def test_discover_config(hue_client): async def test_get_light_state(hass_hue, hue_client): """Test the getting of light state.""" - # Turn office light on and set to 127 brightness, and set light color + # Turn ceiling lights on and set to 127 brightness, and set light color await hass_hue.services.async_call( light.DOMAIN, const.SERVICE_TURN_ON, @@ -591,6 +638,23 @@ async def test_put_light_state(hass, hass_hue, hue_client): ) assert kitchen_result.status == HTTP_UNAUTHORIZED + # Turn the ceiling lights on first and color temp. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_COLOR_TEMP: 20}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, color_temp=50 + ) + + assert ( + hass_hue.states.get("light.ceiling_lights").attributes[light.ATTR_COLOR_TEMP] + == 50 + ) + async def test_put_light_state_script(hass, hass_hue, hue_client): """Test the setting of script variables.""" @@ -832,6 +896,71 @@ async def test_put_light_state_fan(hass_hue, hue_client): assert living_room_fan.state == "on" assert living_room_fan.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + # Check setting the brightness of a fan to 0, 33%, 66% and 100% will respectively turn it off, low, medium or high + # We also check non-cached GET value to exercise the code. + await perform_put_light_state( + hass_hue, hue_client, "fan.living_room_fan", True, brightness=0 + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_OFF + ) + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(33 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_LOW + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 33 + + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(66 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_MEDIUM + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert ( + round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 67 + ) # small rounding error in inverse operation + + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(100 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_HIGH + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 + # pylint: disable=invalid-name async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client): @@ -952,9 +1081,8 @@ async def perform_put_test_on_ceiling_lights( assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 56 -async def perform_get_light_state(client, entity_id, expected_status): +async def perform_get_light_state_by_number(client, entity_number, expected_status): """Test the getting of a light state.""" - entity_number = ENTITY_NUMBERS_BY_ID[entity_id] result = await client.get(f"/api/username/lights/{entity_number}") assert result.status == expected_status @@ -967,6 +1095,14 @@ async def perform_get_light_state(client, entity_id, expected_status): return None +async def perform_get_light_state(client, entity_id, expected_status): + """Test the getting of a light state.""" + entity_number = ENTITY_NUMBERS_BY_ID[entity_id] + return await perform_get_light_state_by_number( + client, entity_number, expected_status + ) + + async def perform_put_light_state( hass_hue, client, @@ -976,11 +1112,16 @@ async def perform_put_light_state( content_type="application/json", hue=None, saturation=None, + color_temp=None, + with_state=True, ): """Test the setting of a light state.""" req_headers = {"Content-Type": content_type} - data = {HUE_API_STATE_ON: is_on} + data = {} + + if with_state: + data[HUE_API_STATE_ON] = is_on if brightness is not None: data[HUE_API_STATE_BRI] = brightness @@ -988,6 +1129,8 @@ async def perform_put_light_state( data[HUE_API_STATE_HUE] = hue if saturation is not None: data[HUE_API_STATE_SAT] = saturation + if color_temp is not None: + data[HUE_API_STATE_CT] = color_temp entity_number = ENTITY_NUMBERS_BY_ID[entity_id] result = await client.put( @@ -1042,3 +1185,291 @@ async def test_unauthorized_user_blocked(hue_client): result_json = await result.json() assert result_json[0]["error"]["description"] == "unauthorized user" + + +async def test_put_then_get_cached_properly(hass, hass_hue, hue_client): + """Test the setting of light states and an immediate readback reads the same values.""" + + # Turn the bedroom light on first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_BRIGHTNESS: 153}, + blocking=True, + ) + + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_ON + assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 153 + + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + # Check that a Hue brightness level of 254 becomes 255 in HA realm. + assert ( + hass.states.get("light.ceiling_lights").attributes[light.ATTR_BRIGHTNESS] == 255 + ) + + # Make sure that the GET response is the same as the PUT response within 2 seconds if the service call is successful and the state doesn't change. + # We simulate a long latence for the actual setting of the entity by forcibly sitting different values directly. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_BRIGHTNESS: 153}, + blocking=True, + ) + + # go through api to get the state back, the value returned should match those set in the last PUT request. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + assert ceiling_json["state"][HUE_API_STATE_HUE] == 4369 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 127 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 254 + + # Make sure that the GET response does not use the cache if PUT response within 2 seconds if the service call is Unsuccessful and the state does not change. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + # go through api to get the state back + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # Now it should be the real value as the state of the entity has changed to OFF. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 0 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 0 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 1 + + # Ensure we read the actual value after exceeding the timeout time. + + # Turn the bedroom light back on first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + { + const.ATTR_ENTITY_ID: "light.ceiling_lights", + light.ATTR_BRIGHTNESS: 127, + light.ATTR_RGB_COLOR: (1, 2, 7), + }, + blocking=True, + ) + + # go through api to get the state back, the value returned should match those set in the last PUT request. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # With no wait, we must be reading what we set via the PUT call. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 4369 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 127 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 254 + + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + + # go through api to get the state back, the value returned should now match the actual values. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # Once we're after the cached duration, we should see the real value. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 41869 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 217 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 127 + + +async def test_put_than_get_when_service_call_fails(hass, hass_hue, hue_client): + """Test putting and getting the light state when the service call fails.""" + + # Turn the bedroom light off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + turn_on_calls = [] + + # Now break the turn on service + @callback + def mock_service_call(call): + """Mock service call.""" + turn_on_calls.append(call) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_ON, mock_service_call, schema=None + ) + + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_OFF + + with patch.object(hue_api, "STATE_CHANGE_WAIT_TIMEOUT", 0.000001): + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + # Ensure we did not actually turn on + assert hass.states.get("light.ceiling_lights").state == STATE_OFF + + # go through api to get the state back, the value returned should NOT match those set in the last PUT request + # as the waiting to check the state change timed out + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + assert ceiling_json["state"][HUE_API_STATE_ON] is False + + +async def test_get_invalid_entity(hass, hass_hue, hue_client): + """Test the setting of light states and an immediate readback reads the same values.""" + + # Check that we get an error with an invalid entity number. + await perform_get_light_state_by_number(hue_client, 999, HTTP_NOT_FOUND) + + +async def test_put_light_state_scene(hass, hass_hue, hue_client): + """Test the setting of scene variables.""" + # Turn the kitchen lights off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.kitchen_lights"}, + blocking=True, + ) + + scene_result = await perform_put_light_state( + hass_hue, hue_client, "scene.light_on", True + ) + + scene_result_json = await scene_result.json() + assert scene_result.status == HTTP_OK + assert len(scene_result_json) == 1 + + assert hass_hue.states.get("light.kitchen_lights").state == STATE_ON + + # Set the brightness on the entity; changing a scene brightness via the hue API will do nothing. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.kitchen_lights", light.ATTR_BRIGHTNESS: 127}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "scene.light_on", True, brightness=254 + ) + + assert hass_hue.states.get("light.kitchen_lights").state == STATE_ON + assert ( + hass_hue.states.get("light.kitchen_lights").attributes[light.ATTR_BRIGHTNESS] + == 127 + ) + + await perform_put_light_state(hass_hue, hue_client, "scene.light_off", True) + assert hass_hue.states.get("light.kitchen_lights").state == STATE_OFF + + +async def test_only_change_contrast(hass, hass_hue, hue_client): + """Test when only changing the contrast of a light state.""" + + # Turn the kitchen lights off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + brightness=254, + with_state=False, + ) + + # Check that only setting the contrast will also turn on the light. + # TODO: It should be noted that a real Hue hub will not allow to change the brightness if the underlying entity is off. + # giving the error: [{"error":{"type":201,"address":"/lights/20/state/bri","description":"parameter, bri, is not modifiable. Device is set to off."}}] + # emulated_hue however will always turn on the light. + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_ON + assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 255 + + +async def test_only_change_hue_or_saturation(hass, hass_hue, hue_client): + """Test setting either the hue or the saturation but not both.""" + + # TODO: The handling of this appears wrong, as setting only one will set the other to 0. + # The return values also appear wrong. + + # Turn the ceiling lights on first and set hue and saturation. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_HS_COLOR: (10, 10)}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, hue=4369 + ) + + assert hass_hue.states.get("light.ceiling_lights").attributes[ + light.ATTR_HS_COLOR + ] == (24, 0) + + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_HS_COLOR: (10, 10)}, + blocking=True, + ) + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, saturation=10 + ) + + assert hass_hue.states.get("light.ceiling_lights").attributes[ + light.ATTR_HS_COLOR + ] == (0, 3) diff --git a/tests/components/fido/test_sensor.py b/tests/components/fido/test_sensor.py index c96e6d0ab58..551a59f0788 100644 --- a/tests/components/fido/test_sensor.py +++ b/tests/components/fido/test_sensor.py @@ -1,6 +1,7 @@ """The test for the fido sensor platform.""" import logging -import sys + +from pyfido.client import PyFidoError from homeassistant.bootstrap import async_setup_component from homeassistant.components.fido import sensor as fido @@ -36,35 +37,12 @@ class FidoClientMockError(FidoClientMock): async def fetch_data(self): """Return fake fetching data.""" - raise PyFidoErrorMock("Fake Error") - - -class PyFidoErrorMock(Exception): - """Fake PyFido Error.""" - - -class PyFidoClientFakeModule: - """Fake pyfido.client module.""" - - PyFidoError = PyFidoErrorMock - - -class PyFidoFakeModule: - """Fake pyfido module.""" - - FidoClient = FidoClientMockError - - -def fake_async_add_entities(component, update_before_add=False): - """Fake async_add_entities function.""" - pass + raise PyFidoError("Fake Error") async def test_fido_sensor(loop, hass): """Test the Fido number sensor.""" - with patch( - "homeassistant.components.fido.sensor.FidoClient", new=FidoClientMock - ), patch("homeassistant.components.fido.sensor.PyFidoError", new=PyFidoErrorMock): + with patch("homeassistant.components.fido.sensor.FidoClient", new=FidoClientMock): config = { "sensor": { "platform": "fido", @@ -87,10 +65,9 @@ async def test_fido_sensor(loop, hass): async def test_error(hass, caplog): """Test the Fido sensor errors.""" caplog.set_level(logging.ERROR) - sys.modules["pyfido"] = PyFidoFakeModule() - sys.modules["pyfido.client"] = PyFidoClientFakeModule() config = {} fake_async_add_entities = MagicMock() - await fido.async_setup_platform(hass, config, fake_async_add_entities) + with patch("homeassistant.components.fido.sensor.FidoClient", FidoClientMockError): + await fido.async_setup_platform(hass, config, fake_async_add_entities) assert fake_async_add_entities.called is False diff --git a/tests/components/firmata/__init__.py b/tests/components/firmata/__init__.py new file mode 100644 index 00000000000..48e58cf5c36 --- /dev/null +++ b/tests/components/firmata/__init__.py @@ -0,0 +1 @@ +"""Tests for the Firmata integration.""" diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py new file mode 100644 index 00000000000..e77f219e320 --- /dev/null +++ b/tests/components/firmata/test_config_flow.py @@ -0,0 +1,92 @@ +"""Test the Firmata config flow.""" +from pymata_express.pymata_express_serial import serial + +from homeassistant import config_entries, setup +from homeassistant.components.firmata.const import CONF_SERIAL_PORT, DOMAIN +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.async_mock import patch + + +async def test_import_cannot_connect_pymata(hass: HomeAssistant) -> None: + """Test we fail with an invalid board.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.firmata.board.PymataExpress.start_aio", + side_effect=RuntimeError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/nonExistent"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_import_cannot_connect_serial(hass: HomeAssistant) -> None: + """Test we fail with an invalid board.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.firmata.board.PymataExpress.start_aio", + side_effect=serial.serialutil.SerialException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/nonExistent"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_import_cannot_connect_serial_timeout(hass: HomeAssistant) -> None: + """Test we fail with an invalid board.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.firmata.board.PymataExpress.start_aio", + side_effect=serial.serialutil.SerialTimeoutException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/nonExistent"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_import(hass: HomeAssistant) -> None: + """Test we create an entry from config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.firmata.board.PymataExpress", autospec=True + ), patch( + "homeassistant.components.firmata.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.firmata.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_SERIAL_PORT: "/dev/nonExistent"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "serial-/dev/nonExistent" + assert result["data"] == { + CONF_NAME: "serial-/dev/nonExistent", + CONF_SERIAL_PORT: "/dev/nonExistent", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index cc194f4f14a..594b3aff2b2 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -104,6 +104,7 @@ async def test_valid_config_with_info(hass): } }, ) + await hass.async_block_till_done() async def test_valid_config_no_name(hass): @@ -114,6 +115,7 @@ async def test_valid_config_no_name(hass): "switch", {"switch": {"platform": "flux", "lights": ["light.desk", "light.lamp"]}}, ) + await hass.async_block_till_done() async def test_invalid_config_no_lights(hass): @@ -122,6 +124,7 @@ async def test_invalid_config_no_lights(hass): assert await async_setup_component( hass, "switch", {"switch": {"platform": "flux", "name": "flux"}} ) + await hass.async_block_till_done() async def test_flux_when_switch_is_off(hass, legacy_patchable_time): @@ -168,6 +171,7 @@ async def test_flux_when_switch_is_off(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() async_fire_time_changed(hass, test_time) await hass.async_block_till_done() @@ -218,6 +222,7 @@ async def test_flux_before_sunrise(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -271,6 +276,7 @@ async def test_flux_before_sunrise_known_location(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -325,6 +331,7 @@ async def test_flux_after_sunrise_before_sunset(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -380,6 +387,7 @@ async def test_flux_after_sunset_before_stop(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -434,6 +442,7 @@ async def test_flux_after_stop_before_sunrise(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -490,6 +499,7 @@ async def test_flux_with_custom_start_stop_times(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -547,6 +557,7 @@ async def test_flux_before_sunrise_stop_next_day(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -608,6 +619,7 @@ async def test_flux_after_sunrise_before_sunset_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -669,6 +681,7 @@ async def test_flux_after_sunset_before_midnight_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -729,6 +742,7 @@ async def test_flux_after_sunset_after_midnight_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -789,6 +803,7 @@ async def test_flux_after_stop_before_sunrise_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -846,6 +861,7 @@ async def test_flux_with_custom_colortemps(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -902,6 +918,7 @@ async def test_flux_with_custom_brightness(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -974,6 +991,7 @@ async def test_flux_with_multiple_lights(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -1033,6 +1051,7 @@ async def test_flux_with_mired(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -1085,6 +1104,7 @@ async def test_flux_with_rgb(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 10f55bd4db3..5e6bbe8b2d4 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -1,4 +1,5 @@ """The tests for Home Assistant frontend.""" +from datetime import timedelta import re import pytest @@ -10,16 +11,25 @@ from homeassistant.components.frontend import ( CONF_THEMES, DOMAIN, EVENT_PANELS_UPDATED, + THEMES_STORAGE_KEY, ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import HTTP_NOT_FOUND from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util import dt from tests.async_mock import patch -from tests.common import async_capture_events +from tests.common import async_capture_events, async_fire_time_changed -CONFIG_THEMES = {DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "red"}}}} +CONFIG_THEMES = { + DOMAIN: { + CONF_THEMES: { + "happy": {"primary-color": "red"}, + "dark": {"primary-color": "black"}, + } + } +} @pytest.fixture @@ -117,7 +127,11 @@ async def test_themes_api(hass, hass_ws_client): msg = await client.receive_json() assert msg["result"]["default_theme"] == "default" - assert msg["result"]["themes"] == {"happy": {"primary-color": "red"}} + assert msg["result"]["default_dark_theme"] is None + assert msg["result"]["themes"] == { + "happy": {"primary-color": "red"}, + "dark": {"primary-color": "black"}, + } # safe mode hass.config.safe_mode = True @@ -130,6 +144,58 @@ async def test_themes_api(hass, hass_ws_client): } +async def test_themes_persist(hass, hass_ws_client, hass_storage): + """Test that theme settings are restores after restart.""" + + hass_storage[THEMES_STORAGE_KEY] = { + "key": THEMES_STORAGE_KEY, + "version": 1, + "data": { + "frontend_default_theme": "happy", + "frontend_default_dark_theme": "dark", + }, + } + + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_theme"] == "happy" + assert msg["result"]["default_dark_theme"] == "dark" + + +async def test_themes_save_storage(hass, hass_storage): + """Test that theme settings are restores after restart.""" + + hass_storage[THEMES_STORAGE_KEY] = { + "key": THEMES_STORAGE_KEY, + "version": 1, + "data": {}, + } + + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "dark", "mode": "dark"}, blocking=True + ) + + # To trigger the call_later + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=60)) + # To execute the save + await hass.async_block_till_done() + + assert hass_storage[THEMES_STORAGE_KEY]["data"] == { + "frontend_default_theme": "happy", + "frontend_default_dark_theme": "dark", + } + + async def test_themes_set_theme(hass, hass_ws_client): """Test frontend.set_theme service.""" assert await async_setup_component(hass, "frontend", CONFIG_THEMES) @@ -153,6 +219,17 @@ async def test_themes_set_theme(hass, hass_ws_client): assert msg["result"]["default_theme"] == "default" + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) + + await hass.services.async_call(DOMAIN, "set_theme", {"name": "none"}, blocking=True) + + await client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_theme"] == "default" + async def test_themes_set_theme_wrong_name(hass, hass_ws_client): """Test frontend.set_theme service called with wrong name.""" @@ -170,6 +247,55 @@ async def test_themes_set_theme_wrong_name(hass, hass_ws_client): assert msg["result"]["default_theme"] == "default" +async def test_themes_set_dark_theme(hass, hass_ws_client): + """Test frontend.set_theme service called with dark mode.""" + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "dark", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] == "dark" + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "default", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] == "default" + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "none", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] is None + + +async def test_themes_set_dark_theme_wrong_name(hass, hass_ws_client): + """Test frontend.set_theme service called with mode dark and wrong name.""" + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "wrong", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] is None + + async def test_themes_reload_themes(hass, hass_ws_client): """Test frontend.reload_themes service.""" assert await async_setup_component(hass, "frontend", CONFIG_THEMES) diff --git a/tests/components/gogogate2/test_cover.py b/tests/components/gogogate2/test_cover.py index 8cffec47e65..5bc9ed9ebd4 100644 --- a/tests/components/gogogate2/test_cover.py +++ b/tests/components/gogogate2/test_cover.py @@ -1,7 +1,4 @@ """Tests for the GogoGate2 component.""" -from datetime import datetime, timedelta -from unittest.mock import MagicMock, patch - from gogogate2_api import GogoGate2Api from gogogate2_api.common import ( ActivateResponse, @@ -24,15 +21,15 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_USERNAME, STATE_CLOSED, - STATE_CLOSING, STATE_OPEN, - STATE_OPENING, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from .common import ComponentFactory +from tests.async_mock import MagicMock + async def test_import_fail( hass: HomeAssistant, component_factory: ComponentFactory @@ -405,11 +402,6 @@ async def test_open_close( ) await hass.async_block_till_done() component_data.api.close_door.assert_called_with(1) - await hass.services.async_call( - HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"}, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.door1").state == STATE_CLOSING component_data.data_update_coordinator.api.info.return_value = closed_door_response await component_data.data_update_coordinator.async_refresh() @@ -422,35 +414,19 @@ async def test_open_close( ) await hass.async_block_till_done() component_data.api.open_door.assert_called_with(1) - await hass.services.async_call( - HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"}, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.door1").state == STATE_OPENING # Assert the mid state does not change when the same status is returned. component_data.data_update_coordinator.api.info.return_value = closed_door_response await component_data.data_update_coordinator.async_refresh() component_data.data_update_coordinator.api.info.return_value = closed_door_response + await component_data.data_update_coordinator.async_refresh() + await component_data.data_update_coordinator.async_refresh() await hass.services.async_call( HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"}, ) await hass.async_block_till_done() - assert hass.states.get("cover.door1").state == STATE_OPENING - - # Assert the mid state times out. - with patch("homeassistant.components.gogogate2.cover.datetime") as datetime_mock: - datetime_mock.now.return_value = datetime.now() + timedelta(seconds=60.1) - component_data.data_update_coordinator.api.info.return_value = ( - closed_door_response - ) - await component_data.data_update_coordinator.async_refresh() - await hass.services.async_call( - HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"}, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.door1").state == STATE_CLOSED + assert hass.states.get("cover.door1").state == STATE_CLOSED async def test_availability( diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 45adc281524..f665fa53ed2 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -190,6 +190,7 @@ DEMO_DEVICES = [ "id": "media_player.lounge_room", "name": {"name": "Lounge room"}, "traits": [ + "action.devices.traits.InputSelector", "action.devices.traits.OnOff", "action.devices.traits.Modes", "action.devices.traits.TransportControl", @@ -228,7 +229,10 @@ DEMO_DEVICES = [ { "id": "climate.hvac", "name": {"name": "Hvac"}, - "traits": ["action.devices.traits.TemperatureSetting"], + "traits": [ + "action.devices.traits.TemperatureSetting", + "action.devices.traits.FanSpeed", + ], "type": "action.devices.types.THERMOSTAT", "willReportState": False, "attributes": { @@ -246,7 +250,10 @@ DEMO_DEVICES = [ { "id": "climate.ecobee", "name": {"name": "Ecobee"}, - "traits": ["action.devices.traits.TemperatureSetting"], + "traits": [ + "action.devices.traits.TemperatureSetting", + "action.devices.traits.FanSpeed", + ], "type": "action.devices.types.THERMOSTAT", "willReportState": False, }, diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index e4beaa14bba..cd268a5c2d9 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -231,6 +231,7 @@ async def test_query_climate_request(hass_fixture, assistant_client, auth_header "thermostatTemperatureAmbient": 23, "thermostatMode": "heatcool", "thermostatTemperatureSetpointLow": 21, + "currentFanSpeedSetting": "Auto Low", } assert devices["climate.hvac"] == { "online": True, @@ -238,6 +239,7 @@ async def test_query_climate_request(hass_fixture, assistant_client, auth_header "thermostatTemperatureAmbient": 22, "thermostatMode": "cool", "thermostatHumidityAmbient": 54, + "currentFanSpeedSetting": "On High", } @@ -288,6 +290,7 @@ async def test_query_climate_request_f(hass_fixture, assistant_client, auth_head "thermostatTemperatureAmbient": -5, "thermostatMode": "heatcool", "thermostatTemperatureSetpointLow": -6.1, + "currentFanSpeedSetting": "Auto Low", } assert devices["climate.hvac"] == { "online": True, @@ -295,6 +298,7 @@ async def test_query_climate_request_f(hass_fixture, assistant_client, auth_head "thermostatTemperatureAmbient": -5.6, "thermostatMode": "cool", "thermostatHumidityAmbient": 54, + "currentFanSpeedSetting": "On High", } hass_fixture.config.units.temperature_unit = const.TEMP_CELSIUS diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index e9795a9320f..6cd99d1fdd1 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -337,8 +337,6 @@ async def test_execute(hass): const.SOURCE_CLOUD, ) - print(result) - assert result == { "requestId": REQ_ID, "payload": { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index adcdbd8291d..854e040119d 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1313,14 +1313,73 @@ async def test_fan_speed(hass): assert calls[0].data == {"entity_id": "fan.living_room_fan", "speed": "medium"} -async def test_modes_media_player(hass): - """Test Media Player Mode trait.""" +async def test_climate_fan_speed(hass): + """Test FanSpeed trait speed control support for climate domain.""" + assert helpers.get_google_type(climate.DOMAIN, None) is not None + assert trait.FanSpeedTrait.supported(climate.DOMAIN, climate.SUPPORT_FAN_MODE, None) + + trt = trait.FanSpeedTrait( + hass, + State( + "climate.living_room_ac", + "on", + attributes={ + "fan_modes": ["auto", "low", "medium", "high"], + "fan_mode": "low", + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "availableFanSpeeds": { + "ordered": True, + "speeds": [ + { + "speed_name": "auto", + "speed_values": [{"speed_synonym": ["auto"], "lang": "en"}], + }, + { + "speed_name": "low", + "speed_values": [{"speed_synonym": ["low"], "lang": "en"}], + }, + { + "speed_name": "medium", + "speed_values": [{"speed_synonym": ["medium"], "lang": "en"}], + }, + { + "speed_name": "high", + "speed_values": [{"speed_synonym": ["high"], "lang": "en"}], + }, + ], + }, + "reversible": False, + } + + assert trt.query_attributes() == { + "currentFanSpeedSetting": "low", + } + + assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeed": "medium"}) + + calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_SET_FAN_MODE) + await trt.execute(trait.COMMAND_FANSPEED, BASIC_DATA, {"fanSpeed": "medium"}, {}) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "climate.living_room_ac", + "fan_mode": "medium", + } + + +async def test_inputselector(hass): + """Test input selector trait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None - assert trait.ModesTrait.supported( + assert trait.InputSelectorTrait.supported( media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE, None ) - trt = trait.ModesTrait( + trt = trait.InputSelectorTrait( hass, State( "media_player.living_room", @@ -1340,56 +1399,29 @@ async def test_modes_media_player(hass): attribs = trt.sync_attributes() assert attribs == { - "availableModes": [ + "availableInputs": [ + {"key": "media", "names": [{"name_synonym": ["media"], "lang": "en"}]}, + {"key": "game", "names": [{"name_synonym": ["game"], "lang": "en"}]}, { - "name": "input source", - "name_values": [ - {"name_synonym": ["input source", "input", "source"], "lang": "en"} - ], - "settings": [ - { - "setting_name": "media", - "setting_values": [ - {"setting_synonym": ["media"], "lang": "en"} - ], - }, - { - "setting_name": "game", - "setting_values": [{"setting_synonym": ["game"], "lang": "en"}], - }, - { - "setting_name": "chromecast", - "setting_values": [ - {"setting_synonym": ["chromecast"], "lang": "en"} - ], - }, - { - "setting_name": "plex", - "setting_values": [{"setting_synonym": ["plex"], "lang": "en"}], - }, - ], - "ordered": False, - } - ] + "key": "chromecast", + "names": [{"name_synonym": ["chromecast"], "lang": "en"}], + }, + {"key": "plex", "names": [{"name_synonym": ["plex"], "lang": "en"}]}, + ], + "orderedInputs": True, } assert trt.query_attributes() == { - "currentModeSettings": {"input source": "game"}, - "on": True, + "currentInput": "game", } - assert trt.can_execute( - trait.COMMAND_MODES, params={"updateModeSettings": {"input source": "media"}}, - ) + assert trt.can_execute(trait.COMMAND_INPUT, params={"newInput": "media"},) calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE ) await trt.execute( - trait.COMMAND_MODES, - BASIC_DATA, - {"updateModeSettings": {"input source": "media"}}, - {}, + trait.COMMAND_INPUT, BASIC_DATA, {"newInput": "media"}, {}, ) assert len(calls) == 1 @@ -1401,6 +1433,11 @@ async def test_modes_input_select(hass): assert helpers.get_google_type(input_select.DOMAIN, None) is not None assert trait.ModesTrait.supported(input_select.DOMAIN, None, None) + trt = trait.ModesTrait( + hass, State("input_select.bla", "unavailable"), BASIC_CONFIG, + ) + assert trt.sync_attributes() == {"availableModes": []} + trt = trait.ModesTrait( hass, State( diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 921b810fe39..3709c4856a2 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -490,3 +490,25 @@ async def test_service_group_set_group_remove_group(hass): group_state = hass.states.get("group.user_test_group") assert group_state is None + + +async def test_group_order(hass): + """Test that order gets incremented when creating a new group.""" + hass.states.async_set("light.bowl", STATE_ON) + + assert await async_setup_component( + hass, + "group", + { + "group": { + "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"}, + "group_one": {"entities": "light.Bowl", "icon": "mdi:work"}, + "group_two": {"entities": "light.Bowl", "icon": "mdi:work"}, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("group.group_zero").attributes["order"] == 0 + assert hass.states.get("group.group_one").attributes["order"] == 1 + assert hass.states.get("group.group_two").attributes["order"] == 2 diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 67fcfb75d5f..311fc6c7e8c 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -92,6 +92,30 @@ async def test_api_host_info_error(hassio_handler, aioclient_mock): assert aioclient_mock.call_count == 1 +async def test_api_core_info(hassio_handler, aioclient_mock): + """Test setup with API Home Assistant Core info.""" + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) + + data = await hassio_handler.get_core_info() + assert aioclient_mock.call_count == 1 + assert data["version_latest"] == "1.0.0" + + +async def test_api_core_info_error(hassio_handler, aioclient_mock): + """Test setup with API Home Assistant Core info error.""" + aioclient_mock.get( + "http://127.0.0.1/core/info", json={"result": "error", "message": None} + ) + + with pytest.raises(HassioAPIError): + await hassio_handler.get_core_info() + + assert aioclient_mock.call_count == 1 + + async def test_api_homeassistant_stop(hassio_handler, aioclient_mock): """Test setup with API Home Assistant stop.""" aioclient_mock.post("http://127.0.0.1/homeassistant/stop", json={"result": "ok"}) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index d0043747835..34ba638410a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -40,6 +40,10 @@ def mock_all(aioclient_mock): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) @@ -51,8 +55,8 @@ async def test_setup_api_ping(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {}) assert result - assert aioclient_mock.call_count == 6 - assert hass.components.hassio.get_homeassistant_version() == "0.110.0" + assert aioclient_mock.call_count == 7 + assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -90,7 +94,7 @@ async def test_setup_api_push_api_data(hass, aioclient_mock): ) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -106,7 +110,7 @@ async def test_setup_api_push_api_data_server_host(hass, aioclient_mock): ) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -118,7 +122,7 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, hass_storag result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -165,7 +169,7 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, hass_storage result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -179,7 +183,7 @@ async def test_setup_core_push_timezone(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" await hass.config.async_update(time_zone="America/New_York") @@ -195,7 +199,7 @@ async def test_setup_hassio_no_additional_data(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" diff --git a/tests/components/hlk_sw16/__init__.py b/tests/components/hlk_sw16/__init__.py new file mode 100644 index 00000000000..3b8278ee353 --- /dev/null +++ b/tests/components/hlk_sw16/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hi-Link HLK-SW16 integration.""" diff --git a/tests/components/hlk_sw16/test_config_flow.py b/tests/components/hlk_sw16/test_config_flow.py new file mode 100644 index 00000000000..6f9d5592893 --- /dev/null +++ b/tests/components/hlk_sw16/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the Hi-Link HLK-SW16 config flow.""" +import asyncio + +from homeassistant import config_entries, setup +from homeassistant.components.hlk_sw16.const import DOMAIN + +from tests.async_mock import patch + + +class MockSW16Client: + """Class to mock the SW16Client client.""" + + def __init__(self, fail): + """Initialise client with failure modes.""" + self.fail = fail + self.disconnect_callback = None + self.in_transaction = False + self.active_transaction = None + + async def setup(self): + """Mock successful setup.""" + fut = asyncio.Future() + fut.set_result(True) + return fut + + async def status(self): + """Mock status based on failure mode.""" + self.in_transaction = True + self.active_transaction = asyncio.Future() + if self.fail: + if self.disconnect_callback: + self.disconnect_callback() + return await self.active_transaction + else: + self.active_transaction.set_result(True) + return self.active_transaction + + def stop(self): + """Mock client stop.""" + self.in_transaction = False + self.active_transaction = None + + +async def create_mock_hlk_sw16_connection(fail): + """Create a mock HLK-SW16 client.""" + client = MockSW16Client(fail) + await client.setup() + return client + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + conf = { + "host": "127.0.0.1", + "port": 8080, + } + + mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False) + + with patch( + "homeassistant.components.hlk_sw16.config_flow.create_hlk_sw16_connection", + return_value=mock_hlk_sw16_connection, + ), patch( + "homeassistant.components.hlk_sw16.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hlk_sw16.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], conf, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "127.0.0.1:8080" + assert result2["data"] == { + "host": "127.0.0.1", + "port": 8080, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False) + + with patch( + "homeassistant.components.hlk_sw16.config_flow.create_hlk_sw16_connection", + return_value=mock_hlk_sw16_connection, + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result3["type"] == "form" + assert result3["errors"] == {} + + result4 = await hass.config_entries.flow.async_configure(result3["flow_id"], conf,) + + assert result4["type"] == "form" + assert result4["errors"] == {"base": "already_configured"} + await hass.async_block_till_done() + + +async def test_import(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + conf = { + "host": "127.0.0.1", + "port": 8080, + } + + mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False) + + with patch( + "homeassistant.components.hlk_sw16.config_flow.connect_client", + return_value=mock_hlk_sw16_connection, + ), patch( + "homeassistant.components.hlk_sw16.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hlk_sw16.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], conf, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "127.0.0.1:8080" + assert result2["data"] == { + "host": "127.0.0.1", + "port": 8080, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_data(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(True) + + conf = { + "host": "127.0.0.1", + "port": 8080, + } + + with patch( + "homeassistant.components.hlk_sw16.config_flow.connect_client", + return_value=mock_hlk_sw16_connection, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], conf, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + conf = { + "host": "127.0.0.1", + "port": 8080, + } + + with patch( + "homeassistant.components.hlk_sw16.config_flow.connect_client", + side_effect=asyncio.TimeoutError, + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], conf, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index dbc28cb1ea8..118ce2d9934 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -10,13 +10,18 @@ from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( AUDIO_CODEC_COPY, CHAR_MOTION_DETECTED, + CHAR_PROGRAMMABLE_SWITCH_EVENT, CONF_AUDIO_CODEC, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + SERV_DOORBELL, SERV_MOTION_SENSOR, + SERV_STATELESS_PROGRAMMABLE_SWITCH, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, ) @@ -601,3 +606,113 @@ async def test_camera_with_a_missing_linked_motion_sensor(hass, run_driver, even assert acc.category == 17 # Camera assert not acc.get_service(SERV_MOTION_SENSOR) + + +async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): + """Test a camera with a linked doorbell sensor can update.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + doorbell_entity_id = "binary_sensor.doorbell" + + hass.states.async_set( + doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + await hass.async_block_till_done() + entity_id = "camera.demo_camera" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Camera( + hass, + run_driver, + "Camera", + entity_id, + 2, + { + CONF_STREAM_SOURCE: "/dev/null", + CONF_SUPPORT_AUDIO: True, + CONF_VIDEO_CODEC: VIDEO_CODEC_H264_OMX, + CONF_AUDIO_CODEC: AUDIO_CODEC_COPY, + CONF_LINKED_DOORBELL_SENSOR: doorbell_entity_id, + }, + ) + bridge = HomeBridge("hass", run_driver, "Test Bridge") + bridge.add_accessory(acc) + + await acc.run_handler() + + assert acc.aid == 2 + assert acc.category == 17 # Camera + + service = acc.get_service(SERV_DOORBELL) + assert service + char = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char + + assert char.value == 0 + + service2 = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) + assert service2 + char2 = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char2 + + assert char2.value == 0 + + hass.states.async_set( + doorbell_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + await hass.async_block_till_done() + assert char.value == 0 + assert char2.value == 0 + + char.set_value(True) + char2.set_value(True) + hass.states.async_set( + doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + await hass.async_block_till_done() + assert char.value == 0 + assert char2.value == 0 + + # Ensure we do not throw when the linked + # doorbell sensor is removed + hass.states.async_remove(doorbell_entity_id) + await hass.async_block_till_done() + await acc.run_handler() + await hass.async_block_till_done() + assert char.value == 0 + assert char2.value == 0 + + +async def test_camera_with_a_missing_linked_doorbell_sensor(hass, run_driver, events): + """Test a camera with a configured linked doorbell sensor that is missing.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + doorbell_entity_id = "binary_sensor.doorbell" + entity_id = "camera.demo_camera" + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Camera( + hass, + run_driver, + "Camera", + entity_id, + 2, + {CONF_LINKED_DOORBELL_SENSOR: doorbell_entity_id}, + ) + bridge = HomeBridge("hass", run_driver, "Test Bridge") + bridge.add_accessory(acc) + + await acc.run_handler() + + assert acc.aid == 2 + assert acc.category == 17 # Camera + + assert not acc.get_service(SERV_DOORBELL) + assert not acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index e4842b93125..9516963a982 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -3,6 +3,7 @@ from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, + CHAR_REMOTE_KEY, CONF_FEATURE_LIST, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, FEATURE_ON_OFF, @@ -377,7 +378,7 @@ async def test_media_player_television_basic(hass, hk_driver, events, caplog): await acc.run_handler() await hass.async_block_till_done() - assert acc.chars_tv == [] + assert acc.chars_tv == [CHAR_REMOTE_KEY] assert acc.chars_speaker == [] assert acc.support_select_source is False @@ -448,7 +449,7 @@ async def test_tv_restore(hass, hk_driver, events): hass, hk_driver, "MediaPlayer", "media_player.simple", 2, None ) assert acc.category == 31 - assert acc.chars_tv == [] + assert acc.chars_tv == [CHAR_REMOTE_KEY] assert acc.chars_speaker == [] assert acc.support_select_source is False assert not hasattr(acc, "char_input_source") @@ -457,7 +458,7 @@ async def test_tv_restore(hass, hk_driver, events): hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 2, None ) assert acc.category == 31 - assert acc.chars_tv == ["RemoteKey"] + assert acc.chars_tv == [CHAR_REMOTE_KEY] assert acc.chars_speaker == [ "Name", "Active", diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py new file mode 100644 index 00000000000..76a850887ca --- /dev/null +++ b/tests/components/humidifier/test_device_condition.py @@ -0,0 +1,314 @@ +"""The tests for Humidifier device conditions.""" +import pytest +import voluptuous_serialize + +import homeassistant.components.automation as automation +from homeassistant.components.humidifier import DOMAIN, const, device_condition +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automation_capabilities, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_conditions(hass, device_reg, entity_reg): + """Test we get the expected conditions from a humidifier.""" + 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(DOMAIN, "test", "5678", device_id=device_entry.id) + hass.states.async_set( + f"{DOMAIN}.test_5678", + STATE_ON, + { + const.ATTR_MODE: const.MODE_AWAY, + const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY], + }, + ) + hass.states.async_set( + "humidifier.test_5678", "attributes", {"supported_features": 1} + ) + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "type": "is_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_mode", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + conditions = await async_get_device_automations(hass, "condition", device_entry.id) + assert_lists_same(conditions, expected_conditions) + + +async def test_get_conditions_toggle_only(hass, device_reg, entity_reg): + """Test we get the expected conditions from a humidifier.""" + 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(DOMAIN, "test", "5678", device_id=device_entry.id) + hass.states.async_set( + f"{DOMAIN}.test_5678", + STATE_ON, + { + const.ATTR_MODE: const.MODE_AWAY, + const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY], + }, + ) + hass.states.async_set( + "humidifier.test_5678", "attributes", {"supported_features": 0} + ) + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "type": "is_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + conditions = await async_get_device_automations(hass, "condition", device_entry.id) + assert_lists_same(conditions, expected_conditions) + + +async def test_if_state(hass, calls): + """Test for turn_on and turn_off conditions.""" + hass.states.async_set( + "humidifier.entity", STATE_ON, {const.ATTR_MODE: const.MODE_AWAY} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_off", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_off {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_mode", + "mode": "away", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_mode - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get("humidifier.entity").state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + hass.states.async_set("humidifier.entity", STATE_OFF) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "is_off event - test_event2" + + hass.states.async_set( + "humidifier.entity", STATE_ON, {const.ATTR_MODE: const.MODE_AWAY} + ) + + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + + assert len(calls) == 3 + assert calls[2].data["some"] == "is_mode - event - test_event3" + + hass.states.async_set( + "humidifier.entity", STATE_ON, {const.ATTR_MODE: const.MODE_HOME} + ) + + # Should not fire + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert len(calls) == 3 + + +async def test_capabilities(hass): + """Test capabilities.""" + hass.states.async_set( + "humidifier.entity", + STATE_ON, + { + const.ATTR_MODE: const.MODE_AWAY, + const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY], + }, + ) + + # Test mode + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_mode", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "available_modes", + "options": [("home", "home"), ("away", "away")], + "required": True, + "type": "select", + } + ] + + +async def test_capabilities_no_state(hass): + """Test capabilities while state not available.""" + # Test mode + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_mode", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + {"name": "available_modes", "options": [], "required": True, "type": "select"} + ] + + +async def test_get_condition_capabilities(hass, device_reg, entity_reg): + """Test we get the expected toggle capabilities.""" + 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(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + conditions = await async_get_device_automations(hass, "condition", device_entry.id) + for condition in conditions: + capabilities = await async_get_device_automation_capabilities( + hass, "condition", condition + ) + assert capabilities == expected_capabilities diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py new file mode 100644 index 00000000000..4f93b4be4de --- /dev/null +++ b/tests/components/humidifier/test_device_trigger.py @@ -0,0 +1,353 @@ +"""The tests for Humidifier device triggers.""" +import datetime + +import pytest +import voluptuous_serialize + +import homeassistant.components.automation as automation +from homeassistant.components.humidifier import DOMAIN, const, device_trigger +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON +from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_fire_time_changed, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers(hass, device_reg, entity_reg): + """Test we get the expected triggers from a humidifier device.""" + 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(DOMAIN, "test", "5678", device_id=device_entry.id) + entity_id = f"{DOMAIN}.test_5678" + hass.states.async_set( + entity_id, + STATE_ON, + { + const.ATTR_HUMIDITY: 23, + const.ATTR_MODE: "home", + const.ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_SUPPORTED_FEATURES: 1, + }, + ) + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "target_humidity_changed", + "device_id": device_entry.id, + "entity_id": entity_id, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "turned_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "turned_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_state_change(hass, calls): + """Test for turn_on and turn_off triggers firing.""" + hass.states.async_set( + "humidifier.entity", + STATE_ON, + { + const.ATTR_HUMIDITY: 23, + const.ATTR_MODE: "home", + const.ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_SUPPORTED_FEATURES: 1, + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "target_humidity_changed", + "below": 20, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed_below"}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "target_humidity_changed", + "above": 30, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed_above"}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "target_humidity_changed", + "above": 30, + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed_above_for"}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "turned_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + + # Fake that the humidity is changing + hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 7}) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "target_humidity_changed_below" + + # Fake that the humidity is changing + hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 37}) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "target_humidity_changed_above" + + # Wait 6 minutes + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=6)) + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "target_humidity_changed_above_for" + + # Fake turn off + hass.states.async_set("humidifier.entity", STATE_OFF, {const.ATTR_HUMIDITY: 37}) + await hass.async_block_till_done() + assert len(calls) == 4 + assert ( + calls[3].data["some"] == "turn_off device - humidifier.entity - on - off - None" + ) + + # Fake turn on + hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 37}) + await hass.async_block_till_done() + assert len(calls) == 5 + assert ( + calls[4].data["some"] == "turn_on device - humidifier.entity - off - on - None" + ) + + +async def test_invalid_config(hass, calls): + """Test for turn_on and turn_off triggers firing.""" + hass.states.async_set( + "humidifier.entity", + STATE_ON, + { + const.ATTR_HUMIDITY: 23, + const.ATTR_MODE: "home", + const.ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_SUPPORTED_FEATURES: 1, + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "target_humidity_changed", + "below": 20, + "invalid": "invalid", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed"}, + }, + }, + ] + }, + ) + + # Fake that the humidity is changing + hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 7}) + await hass.async_block_till_done() + # Should not trigger for invalid config + assert len(calls) == 0 + + +async def test_get_trigger_capabilities_on(hass): + """Test we get the expected capabilities from a humidifier trigger.""" + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": "humidifier", + "type": "turned_on", + "entity_id": "humidifier.upstairs", + "above": "23", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"name": "for", "optional": True, "type": "positive_time_period_dict"}] + + +async def test_get_trigger_capabilities_off(hass): + """Test we get the expected capabilities from a humidifier trigger.""" + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": "humidifier", + "type": "turned_off", + "entity_id": "humidifier.upstairs", + "above": "23", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"name": "for", "optional": True, "type": "positive_time_period_dict"}] + + +async def test_get_trigger_capabilities_humidity(hass): + """Test we get the expected capabilities from a humidifier trigger.""" + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": "humidifier", + "type": "target_humidity_changed", + "entity_id": "humidifier.upstairs", + "above": "23", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "description": {"suffix": "%"}, + "name": "above", + "optional": True, + "type": "integer", + }, + { + "description": {"suffix": "%"}, + "name": "below", + "optional": True, + "type": "integer", + }, + {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + ] diff --git a/tests/components/humidifier/test_intent.py b/tests/components/humidifier/test_intent.py new file mode 100644 index 00000000000..18c5b632aa6 --- /dev/null +++ b/tests/components/humidifier/test_intent.py @@ -0,0 +1,208 @@ +"""Tests for the humidifier intents.""" +from homeassistant.components.humidifier import ( + ATTR_AVAILABLE_MODES, + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + intent, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.helpers.intent import IntentHandleError + +from tests.common import async_mock_service + + +async def test_intent_set_humidity(hass): + """Test the set humidity intent.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} + ) + humidity_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + turn_on_calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + result = await hass.helpers.intent.async_handle( + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + ) + await hass.async_block_till_done() + + assert result.speech["plain"]["speech"] == "The bedroom humidifier is set to 50%" + + assert len(turn_on_calls) == 0 + assert len(humidity_calls) == 1 + call = humidity_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_SET_HUMIDITY + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert call.data.get(ATTR_HUMIDITY) == 50 + + +async def test_intent_set_humidity_and_turn_on(hass): + """Test the set humidity intent for turned off humidifier.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", STATE_OFF, {ATTR_HUMIDITY: 40} + ) + humidity_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + turn_on_calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + result = await hass.helpers.intent.async_handle( + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + ) + await hass.async_block_till_done() + + assert ( + result.speech["plain"]["speech"] + == "Turned bedroom humidifier on and set humidity to 50%" + ) + + assert len(turn_on_calls) == 1 + call = turn_on_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert len(humidity_calls) == 1 + call = humidity_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_SET_HUMIDITY + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert call.data.get(ATTR_HUMIDITY) == 50 + + +async def test_intent_set_mode(hass): + """Test the set mode intent.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", + STATE_ON, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: "home", + }, + ) + mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + turn_on_calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + result = await hass.helpers.intent.async_handle( + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + ) + await hass.async_block_till_done() + + assert ( + result.speech["plain"]["speech"] + == "The mode for bedroom humidifier is set to away" + ) + + assert len(turn_on_calls) == 0 + assert len(mode_calls) == 1 + call = mode_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_SET_MODE + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert call.data.get(ATTR_MODE) == "away" + + +async def test_intent_set_mode_and_turn_on(hass): + """Test the set mode intent.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", + STATE_OFF, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: "home", + }, + ) + mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + turn_on_calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + result = await hass.helpers.intent.async_handle( + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + ) + await hass.async_block_till_done() + + assert ( + result.speech["plain"]["speech"] + == "Turned bedroom humidifier on and set away mode" + ) + + assert len(turn_on_calls) == 1 + call = turn_on_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert len(mode_calls) == 1 + call = mode_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_SET_MODE + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert call.data.get(ATTR_MODE) == "away" + + +async def test_intent_set_mode_tests_feature(hass): + """Test the set mode intent where modes are not supported.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} + ) + mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + await intent.async_setup_intents(hass) + + try: + await hass.helpers.intent.async_handle( + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + ) + assert False, "handling intent should have raised" + except IntentHandleError as err: + assert str(err) == "Entity bedroom humidifier does not support modes" + + assert len(mode_calls) == 0 + + +async def test_intent_set_unknown_mode(hass): + """Test the set mode intent for unsupported mode.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", + STATE_ON, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: "home", + }, + ) + mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + await intent.async_setup_intents(hass) + + try: + await hass.helpers.intent.async_handle( + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "eco"}}, + ) + assert False, "handling intent should have raised" + except IntentHandleError as err: + assert str(err) == "Entity bedroom humidifier does not support eco mode" + + assert len(mode_calls) == 0 diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 02a98a527ac..7e2a9f5d9c3 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -1059,6 +1059,148 @@ async def test_event_listener_component_override_measurement( write_api.reset_mock() +@pytest.mark.parametrize( + "mock_client, config_ext, get_write_api, get_mock_call", + [ + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + _get_write_api_mock_v1, + influxdb.DEFAULT_API_VERSION, + ), + ( + influxdb.API_VERSION_2, + BASE_V2_CONFIG, + _get_write_api_mock_v2, + influxdb.API_VERSION_2, + ), + ], + indirect=["mock_client", "get_mock_call"], +) +async def test_event_listener_ignore_attributes( + hass, mock_client, config_ext, get_write_api, get_mock_call +): + """Test the event listener with overridden measurements.""" + config = { + "ignore_attributes": ["ignore"], + "component_config": { + "sensor.fake_humidity": {"ignore_attributes": ["id_ignore"]} + }, + "component_config_glob": { + "binary_sensor.*motion": {"ignore_attributes": ["glob_ignore"]} + }, + "component_config_domain": { + "climate": {"ignore_attributes": ["domain_ignore"]} + }, + } + config.update(config_ext) + handler_method = await _setup(hass, mock_client, config, get_write_api) + + test_components = [ + { + "domain": "sensor", + "id": "fake_humidity", + "attrs": {"glob_ignore": 1, "domain_ignore": 1}, + }, + { + "domain": "binary_sensor", + "id": "fake_motion", + "attrs": {"id_ignore": 1, "domain_ignore": 1}, + }, + { + "domain": "climate", + "id": "fake_thermostat", + "attrs": {"id_ignore": 1, "glob_ignore": 1}, + }, + ] + for comp in test_components: + entity_id = f"{comp['domain']}.{comp['id']}" + state = MagicMock( + state=1, + domain=comp["domain"], + entity_id=entity_id, + object_id=comp["id"], + attributes={ + "ignore": 1, + "id_ignore": 1, + "glob_ignore": 1, + "domain_ignore": 1, + }, + ) + event = MagicMock(data={"new_state": state}, time_fired=12345) + fields = {"value": 1} + fields.update(comp["attrs"]) + body = [ + { + "measurement": entity_id, + "tags": {"domain": comp["domain"], "entity_id": comp["id"]}, + "time": 12345, + "fields": fields, + } + ] + handler_method(event) + hass.data[influxdb.DOMAIN].block_till_done() + + write_api = get_write_api(mock_client) + assert write_api.call_count == 1 + assert write_api.call_args == get_mock_call(body) + write_api.reset_mock() + + +@pytest.mark.parametrize( + "mock_client, config_ext, get_write_api, get_mock_call", + [ + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + _get_write_api_mock_v1, + influxdb.DEFAULT_API_VERSION, + ), + ( + influxdb.API_VERSION_2, + BASE_V2_CONFIG, + _get_write_api_mock_v2, + influxdb.API_VERSION_2, + ), + ], + indirect=["mock_client", "get_mock_call"], +) +async def test_event_listener_ignore_attributes_overlapping_entities( + hass, mock_client, config_ext, get_write_api, get_mock_call +): + """Test the event listener with overridden measurements.""" + config = { + "component_config": {"sensor.fake": {"override_measurement": "units"}}, + "component_config_domain": {"sensor": {"ignore_attributes": ["ignore"]}}, + } + config.update(config_ext) + handler_method = await _setup(hass, mock_client, config, get_write_api) + + state = MagicMock( + state=1, + domain="sensor", + entity_id="sensor.fake", + object_id="fake", + attributes={"ignore": 1}, + ) + event = MagicMock(data={"new_state": state}, time_fired=12345) + body = [ + { + "measurement": "units", + "tags": {"domain": "sensor", "entity_id": "fake"}, + "time": 12345, + "fields": {"value": 1}, + } + ] + handler_method(event) + hass.data[influxdb.DOMAIN].block_till_done() + + write_api = get_write_api(mock_client) + assert write_api.call_count == 1 + assert write_api.call_args == get_mock_call(body) + write_api.reset_mock() + + @pytest.mark.parametrize( "mock_client, config_ext, get_write_api, get_mock_call", [ diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index 515543f3cf5..1e269438ad5 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -143,15 +143,16 @@ async def init_integration( entry.add_to_hass(hass) + mock_connection( + aioclient_mock, + host=host, + port=port, + ssl=ssl, + base_path=base_path, + conn_error=conn_error, + ) + if not skip_setup: - mock_connection( - aioclient_mock, - host=host, - port=port, - ssl=ssl, - base_path=base_path, - conn_error=conn_error, - ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index b9a1d833eda..7133bf3cde7 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -218,7 +218,7 @@ async def test_user_device_exists_abort( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort user flow if printer already configured.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, aioclient_mock, skip_setup=True) user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -233,7 +233,7 @@ async def test_zeroconf_device_exists_abort( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow if printer already configured.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, aioclient_mock, skip_setup=True) discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() result = await hass.config_entries.flow.async_init( @@ -248,7 +248,7 @@ async def test_zeroconf_with_uuid_device_exists_abort( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow if printer already configured.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, aioclient_mock, skip_setup=True) discovery_info = { **MOCK_ZEROCONF_IPP_SERVICE_INFO, diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py index 4cc30958b23..6b6872d5f67 100644 --- a/tests/components/iqvia/test_config_flow.py +++ b/tests/components/iqvia/test_config_flow.py @@ -1,7 +1,9 @@ """Define tests for the IQVIA config flow.""" from homeassistant import data_entry_flow -from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN, config_flow +from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from tests.async_mock import patch from tests.common import MockConfigEntry @@ -9,57 +11,49 @@ async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" conf = {CONF_ZIP_CODE: "12345"} - MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass + MockConfigEntry(domain=DOMAIN, unique_id="12345", data=conf).add_to_hass(hass) - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_ZIP_CODE: "identifier_exists"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_invalid_zip_code(hass): """Test that an invalid ZIP code key throws an error.""" conf = {CONF_ZIP_CODE: "abcde"} - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) - result = await flow.async_step_user(user_input=conf) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_ZIP_CODE: "invalid_zip_code"} async def test_show_form(hass): """Test that the form is served with no input.""" - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" -async def test_step_import(hass): - """Test that the import step works.""" - conf = {CONF_ZIP_CODE: "12345"} - - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass - - result = await flow.async_step_import(import_config=conf) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345" - assert result["data"] == {CONF_ZIP_CODE: "12345"} - - async def test_step_user(hass): - """Test that the user step works.""" + """Test that the user step works (without MFA).""" conf = {CONF_ZIP_CODE: "12345"} - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass + with patch( + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) - result = await flow.async_step_user(user_input=conf) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345" - assert result["data"] == {CONF_ZIP_CODE: "12345"} + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "12345" + assert result["data"] == {CONF_ZIP_CODE: "12345"} diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index f3d4351ae29..984bbbf7c75 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -20,7 +20,7 @@ from tests.async_mock import patch from tests.common import MockConfigEntry, async_fire_time_changed -async def test_setup_with_config(hass): +async def test_setup_with_config(hass, legacy_patchable_time): """Test that we import the config and setup the client.""" config = { islamic_prayer_times.DOMAIN: {islamic_prayer_times.CONF_CALC_METHOD: "isna"} @@ -33,9 +33,10 @@ async def test_setup_with_config(hass): await async_setup_component(hass, islamic_prayer_times.DOMAIN, config) is True ) + await hass.async_block_till_done() -async def test_successful_config_entry(hass): +async def test_successful_config_entry(hass, legacy_patchable_time): """Test that Islamic Prayer Times is configured successfully.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},) @@ -46,6 +47,7 @@ async def test_successful_config_entry(hass): return_value=PRAYER_TIMES, ): await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert entry.state == config_entries.ENTRY_STATE_LOADED assert entry.options == { @@ -53,7 +55,7 @@ async def test_successful_config_entry(hass): } -async def test_setup_failed(hass): +async def test_setup_failed(hass, legacy_patchable_time): """Test Islamic Prayer Times failed due to an error.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},) @@ -65,10 +67,11 @@ async def test_setup_failed(hass): side_effect=InvalidResponseError(), ): await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert entry.state == config_entries.ENTRY_STATE_SETUP_RETRY -async def test_unload_entry(hass): +async def test_unload_entry(hass, legacy_patchable_time): """Test removing Islamic Prayer Times.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},) entry.add_to_hass(hass) @@ -95,6 +98,7 @@ async def test_islamic_prayer_times_timestamp_format(hass, legacy_patchable_time return_value=PRAYER_TIMES, ), patch("homeassistant.util.dt.now", return_value=NOW): await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert ( hass.data[islamic_prayer_times.DOMAIN].prayer_times_info diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index b46be5c926c..1660ec422f3 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -243,12 +243,14 @@ class TestLight(unittest.TestCase): assert {} == data # One of the light profiles - prof_name, prof_h, prof_s, prof_bri = "relax", 35.932, 69.412, 144 + prof_name, prof_h, prof_s, prof_bri, prof_t = "relax", 35.932, 69.412, 144, 0 # Test light profiles common.turn_on(self.hass, ent1.entity_id, profile=prof_name) # Specify a profile and a brightness attribute to overwrite it - common.turn_on(self.hass, ent2.entity_id, profile=prof_name, brightness=100) + common.turn_on( + self.hass, ent2.entity_id, profile=prof_name, brightness=100, transition=1 + ) self.hass.block_till_done() @@ -256,12 +258,14 @@ class TestLight(unittest.TestCase): assert { light.ATTR_BRIGHTNESS: prof_bri, light.ATTR_HS_COLOR: (prof_h, prof_s), + light.ATTR_TRANSITION: prof_t, } == data _, data = ent2.last_call("turn_on") assert { light.ATTR_BRIGHTNESS: 100, light.ATTR_HS_COLOR: (prof_h, prof_s), + light.ATTR_TRANSITION: 1, } == data # Test toggle with parameters @@ -271,6 +275,7 @@ class TestLight(unittest.TestCase): assert { light.ATTR_BRIGHTNESS: 255, light.ATTR_HS_COLOR: (prof_h, prof_s), + light.ATTR_TRANSITION: prof_t, } == data # Test bad data @@ -314,8 +319,8 @@ class TestLight(unittest.TestCase): # Setup a wrong light file with open(user_light_file, "w") as user_file: - user_file.write("id,x,y,brightness\n") - user_file.write("I,WILL,NOT,WORK\n") + user_file.write("id,x,y,brightness,transition\n") + user_file.write("I,WILL,NOT,WORK,EVER\n") assert not setup_component( self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} @@ -347,7 +352,11 @@ class TestLight(unittest.TestCase): _, data = ent1.last_call("turn_on") assert light.is_on(self.hass, ent1.entity_id) - assert {light.ATTR_HS_COLOR: (71.059, 100), light.ATTR_BRIGHTNESS: 100} == data + assert { + light.ATTR_HS_COLOR: (71.059, 100), + light.ATTR_BRIGHTNESS: 100, + light.ATTR_TRANSITION: 0, + } == data common.turn_on(self.hass, ent1.entity_id, profile="test_off") @@ -356,7 +365,48 @@ class TestLight(unittest.TestCase): _, data = ent1.last_call("turn_off") assert not light.is_on(self.hass, ent1.entity_id) - assert {} == data + assert {light.ATTR_TRANSITION: 0} == data + + def test_light_profiles_with_transition(self): + """Test light profiles with transition.""" + platform = getattr(self.hass.components, "test.light") + platform.init() + + user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE) + + with open(user_light_file, "w") as user_file: + user_file.write("id,x,y,brightness,transition\n") + user_file.write("test,.4,.6,100,2\n") + user_file.write("test_off,0,0,0,0\n") + + assert setup_component( + self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} + ) + self.hass.block_till_done() + + ent1, _, _ = platform.ENTITIES + + common.turn_on(self.hass, ent1.entity_id, profile="test") + + self.hass.block_till_done() + + _, data = ent1.last_call("turn_on") + + assert light.is_on(self.hass, ent1.entity_id) + assert { + light.ATTR_HS_COLOR: (71.059, 100), + light.ATTR_BRIGHTNESS: 100, + light.ATTR_TRANSITION: 2, + } == data + + common.turn_on(self.hass, ent1.entity_id, profile="test_off") + + self.hass.block_till_done() + + _, data = ent1.last_call("turn_off") + + assert not light.is_on(self.hass, ent1.entity_id) + assert {light.ATTR_TRANSITION: 0} == data def test_default_profiles_group(self): """Test default turn-on light profile for all lights.""" @@ -377,7 +427,9 @@ class TestLight(unittest.TestCase): return StringIO(profile_data) return real_open(path, *args, **kwargs) - profile_data = "id,x,y,brightness\ngroup.all_lights.default,.4,.6,99\n" + profile_data = ( + "id,x,y,brightness,transition\ngroup.all_lights.default,.4,.6,99,2\n" + ) with mock.patch("os.path.isfile", side_effect=_mock_isfile), mock.patch( "builtins.open", side_effect=_mock_open ), mock_storage(): @@ -390,7 +442,11 @@ class TestLight(unittest.TestCase): common.turn_on(self.hass, ent.entity_id) self.hass.block_till_done() _, data = ent.last_call("turn_on") - assert {light.ATTR_HS_COLOR: (71.059, 100), light.ATTR_BRIGHTNESS: 99} == data + assert { + light.ATTR_HS_COLOR: (71.059, 100), + light.ATTR_BRIGHTNESS: 99, + light.ATTR_TRANSITION: 2, + } == data def test_default_profiles_light(self): """Test default turn-on light profile for a specific light.""" @@ -412,9 +468,9 @@ class TestLight(unittest.TestCase): return real_open(path, *args, **kwargs) profile_data = ( - "id,x,y,brightness\n" - + "group.all_lights.default,.3,.5,200\n" - + "light.ceiling_2.default,.6,.6,100\n" + "id,x,y,brightness,transition\n" + + "group.all_lights.default,.3,.5,200,0\n" + + "light.ceiling_2.default,.6,.6,100,3\n" ) with mock.patch("os.path.isfile", side_effect=_mock_isfile), mock.patch( "builtins.open", side_effect=_mock_open @@ -430,7 +486,11 @@ class TestLight(unittest.TestCase): common.turn_on(self.hass, dev.entity_id) self.hass.block_till_done() _, data = dev.last_call("turn_on") - assert {light.ATTR_HS_COLOR: (50.353, 100), light.ATTR_BRIGHTNESS: 100} == data + assert { + light.ATTR_HS_COLOR: (50.353, 100), + light.ATTR_BRIGHTNESS: 100, + light.ATTR_TRANSITION: 3, + } == data async def test_light_context(hass, hass_admin_user): diff --git a/tests/components/linky/__init__.py b/tests/components/linky/__init__.py deleted file mode 100644 index f461885e384..00000000000 --- a/tests/components/linky/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Linky component.""" diff --git a/tests/components/linky/conftest.py b/tests/components/linky/conftest.py deleted file mode 100644 index 93e3ff78d2b..00000000000 --- a/tests/components/linky/conftest.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Linky generic test utils.""" -import pytest - -from tests.async_mock import patch - - -@pytest.fixture(autouse=True) -def patch_fakeuseragent(): - """Stub out fake useragent dep that makes requests.""" - with patch("pylinky.client.UserAgent", return_value="Test Browser"): - yield diff --git a/tests/components/linky/test_config_flow.py b/tests/components/linky/test_config_flow.py deleted file mode 100644 index f39f0da7d99..00000000000 --- a/tests/components/linky/test_config_flow.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Tests for the Linky config flow.""" -from pylinky.exceptions import ( - PyLinkyAccessException, - PyLinkyEnedisException, - PyLinkyException, - PyLinkyWrongLoginException, -) -import pytest - -from homeassistant import data_entry_flow -from homeassistant.components.linky.const import DEFAULT_TIMEOUT, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -from homeassistant.helpers.typing import HomeAssistantType - -from tests.async_mock import Mock, patch -from tests.common import MockConfigEntry - -USERNAME = "username@hotmail.fr" -USERNAME_2 = "username@free.fr" -PASSWORD = "password" -TIMEOUT = 20 - - -@pytest.fixture(name="login") -def mock_controller_login(): - """Mock a successful login.""" - with patch( - "homeassistant.components.linky.config_flow.LinkyClient" - ) as service_mock: - service_mock.return_value.login = Mock(return_value=True) - service_mock.return_value.close_session = Mock(return_value=None) - yield service_mock - - -@pytest.fixture(name="fetch_data") -def mock_controller_fetch_data(): - """Mock a successful get data.""" - with patch( - "homeassistant.components.linky.config_flow.LinkyClient" - ) as service_mock: - service_mock.return_value.fetch_data = Mock(return_value={}) - service_mock.return_value.close_session = Mock(return_value=None) - yield service_mock - - -async def test_user(hass: HomeAssistantType, login, fetch_data): - """Test user config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=None - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - # test with all provided - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == USERNAME - assert result["title"] == USERNAME - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_TIMEOUT] == DEFAULT_TIMEOUT - - -async def test_import(hass: HomeAssistantType, login, fetch_data): - """Test import step.""" - # import with username and password - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == USERNAME - assert result["title"] == USERNAME - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_TIMEOUT] == DEFAULT_TIMEOUT - - # import with all - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_USERNAME: USERNAME_2, - CONF_PASSWORD: PASSWORD, - CONF_TIMEOUT: TIMEOUT, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == USERNAME_2 - assert result["title"] == USERNAME_2 - assert result["data"][CONF_USERNAME] == USERNAME_2 - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_TIMEOUT] == TIMEOUT - - -async def test_abort_if_already_setup(hass: HomeAssistantType, login, fetch_data): - """Test we abort if Linky is already setup.""" - MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - unique_id=USERNAME, - ).add_to_hass(hass) - - # Should fail, same USERNAME (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - # Should fail, same USERNAME (flow) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_login_failed(hass: HomeAssistantType, login): - """Test when we have errors during login.""" - login.return_value.login.side_effect = PyLinkyAccessException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "access"} - hass.config_entries.flow.async_abort(result["flow_id"]) - - login.return_value.login.side_effect = PyLinkyWrongLoginException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "wrong_login"} - hass.config_entries.flow.async_abort(result["flow_id"]) - - -async def test_fetch_failed(hass: HomeAssistantType, login): - """Test when we have errors during fetch.""" - login.return_value.fetch_data.side_effect = PyLinkyAccessException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "access"} - hass.config_entries.flow.async_abort(result["flow_id"]) - - login.return_value.fetch_data.side_effect = PyLinkyEnedisException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "enedis"} - hass.config_entries.flow.async_abort(result["flow_id"]) - - login.return_value.fetch_data.side_effect = PyLinkyException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} - hass.config_entries.flow.async_abort(result["flow_id"]) diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 0fc98d9460e..dbf390df57b 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -34,6 +34,7 @@ async def test_get_actions_support_open(hass, device_reg, entity_reg): 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() config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -77,6 +78,7 @@ async def test_get_actions_not_support_open(hass, device_reg, entity_reg): 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() config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -146,6 +148,7 @@ async def test_action(hass): ] }, ) + await hass.async_block_till_done() lock_calls = async_mock_service(hass, "lock", "lock") unlock_calls = async_mock_service(hass, "lock", "unlock") diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 8b527219818..f264f75e2b0 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -2,7 +2,6 @@ # pylint: disable=protected-access,invalid-name import collections from datetime import datetime, timedelta -from functools import partial import json import logging import unittest @@ -572,43 +571,6 @@ class TestComponentLogbook(unittest.TestCase): entries[5], pointC, "included", domain="light", entity_id=entity_id4 ) - def test_exclude_attribute_changes(self): - """Test if events of attribute changes are filtered.""" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=1) - pointC = pointB + timedelta(minutes=1) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - state_off = ha.State("light.kitchen", "off", {}, pointA, pointA).as_dict() - state_100 = ha.State( - "light.kitchen", "on", {"brightness": 100}, pointB, pointB - ).as_dict() - state_200 = ha.State( - "light.kitchen", "on", {"brightness": 200}, pointB, pointC - ).as_dict() - - eventA = self.create_state_changed_event_from_old_new( - "light.kitchen", pointB, state_off, state_100 - ) - eventB = self.create_state_changed_event_from_old_new( - "light.kitchen", pointC, state_100, state_200 - ) - - entities_filter = convert_include_exclude_filter( - logbook.CONFIG_SCHEMA({logbook.DOMAIN: {}})[logbook.DOMAIN] - ) - events = [ - e - for e in (eventA, eventB) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache)) - - assert len(entries) == 1 - self.assert_entry( - entries[0], pointB, "kitchen", domain="light", entity_id="light.kitchen" - ) - def test_home_assistant_start_stop_grouped(self): """Test if HA start and stop events are grouped. @@ -1407,7 +1369,7 @@ async def test_logbook_view_period_entity(hass, hass_client): entity_id_second = "switch.second" hass.states.async_set(entity_id_second, STATE_OFF) hass.states.async_set(entity_id_second, STATE_ON) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1600,7 +1562,7 @@ async def test_logbook_view_end_time_entity(hass, hass_client): entity_id_second = "switch.second" hass.states.async_set(entity_id_second, STATE_OFF) hass.states.async_set(entity_id_second, STATE_ON) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1672,7 +1634,7 @@ async def test_logbook_entity_filter_with_automations(hass, hass_client): ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1738,7 +1700,7 @@ async def test_filter_continuous_sensor_values(hass, hass_client): hass.states.async_set(entity_id_third, STATE_OFF, {"unit_of_measurement": "foo"}) hass.states.async_set(entity_id_third, STATE_ON, {"unit_of_measurement": "foo"}) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1772,7 +1734,7 @@ async def test_exclude_new_entities(hass, hass_client): hass.states.async_set(entity_id2, STATE_OFF) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1813,7 +1775,7 @@ async def test_exclude_removed_entities(hass, hass_client): hass.states.async_remove(entity_id) hass.states.async_remove(entity_id2) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1835,6 +1797,46 @@ async def test_exclude_removed_entities(hass, hass_client): assert response_json[2]["entity_id"] == entity_id2 +async def test_exclude_attribute_changes(hass, hass_client): + """Test if events of attribute changes are filtered.""" + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", {}) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + hass.states.async_set("light.kitchen", STATE_OFF) + hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 100}) + hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 200}) + hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 300}) + hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) + hass.states.async_set("light.kitchen", STATE_OFF) + + await hass.async_block_till_done() + + await hass.async_add_job(trigger_db_commit, hass) + await hass.async_block_till_done() + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + client = await hass_client() + + # Today time 00:00:00 + start = dt_util.utcnow().date() + start_date = datetime(start.year, start.month, start.day) + + # Test today entries without filters + response = await client.get(f"/api/logbook/{start_date.isoformat()}") + assert response.status == 200 + response_json = await response.json() + + assert len(response_json) == 3 + assert response_json[0]["domain"] == "homeassistant" + assert response_json[1]["message"] == "turned on" + assert response_json[1]["entity_id"] == "light.kitchen" + assert response_json[2]["message"] == "turned off" + assert response_json[2]["entity_id"] == "light.kitchen" + + class MockLazyEventPartialState(ha.Event): """Minimal mock of a Lazy event.""" diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index 75c294775ed..06a65b6ba87 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -7,10 +7,7 @@ from tests.async_mock import patch @pytest.fixture(autouse=True) def patch_requests(): """Stub out services that makes requests.""" - patch_client = patch("homeassistant.components.meteo_france.meteofranceClient") - patch_weather_alert = patch( - "homeassistant.components.meteo_france.VigilanceMeteoFranceProxy" - ) + patch_client = patch("homeassistant.components.meteo_france.MeteoFranceClient") - with patch_client, patch_weather_alert: + with patch_client: yield diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py index 8a5c734a0ed..650a88df84e 100644 --- a/tests/components/meteo_france/test_config_flow.py +++ b/tests/components/meteo_france/test_config_flow.py @@ -1,29 +1,82 @@ """Tests for the Meteo-France config flow.""" -from meteofrance.client import meteofranceError +from meteofrance.model import Place import pytest from homeassistant import data_entry_flow -from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN +from homeassistant.components.meteo_france.const import ( + CONF_CITY, + DOMAIN, + FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, +) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.helpers.typing import HomeAssistantType from tests.async_mock import patch from tests.common import MockConfigEntry CITY_1_POSTAL = "74220" CITY_1_NAME = "La Clusaz" -CITY_2_POSTAL_DISTRICT_1 = "69001" -CITY_2_POSTAL_DISTRICT_4 = "69004" -CITY_2_NAME = "Lyon" +CITY_1_LAT = 45.90417 +CITY_1_LON = 6.42306 +CITY_1_COUNTRY = "FR" +CITY_1_ADMIN = "Rhône-Alpes" +CITY_1_ADMIN2 = "74" +CITY_1 = Place( + { + "name": CITY_1_NAME, + "lat": CITY_1_LAT, + "lon": CITY_1_LON, + "country": CITY_1_COUNTRY, + "admin": CITY_1_ADMIN, + "admin2": CITY_1_ADMIN2, + } +) + +CITY_2_NAME = "Auch" +CITY_2_LAT = 43.64528 +CITY_2_LON = 0.58861 +CITY_2_COUNTRY = "FR" +CITY_2_ADMIN = "Midi-Pyrénées" +CITY_2_ADMIN2 = "32" +CITY_2 = Place( + { + "name": CITY_2_NAME, + "lat": CITY_2_LAT, + "lon": CITY_2_LON, + "country": CITY_2_COUNTRY, + "admin": CITY_2_ADMIN, + "admin2": CITY_2_ADMIN2, + } +) + +CITY_3_NAME = "Auchel" +CITY_3_LAT = 50.50833 +CITY_3_LON = 2.47361 +CITY_3_COUNTRY = "FR" +CITY_3_ADMIN = "Nord-Pas-de-Calais" +CITY_3_ADMIN2 = "62" +CITY_3 = Place( + { + "name": CITY_3_NAME, + "lat": CITY_3_LAT, + "lon": CITY_3_LON, + "country": CITY_3_COUNTRY, + "admin": CITY_3_ADMIN, + "admin2": CITY_3_ADMIN2, + } +) -@pytest.fixture(name="client_1") -def mock_controller_client_1(): +@pytest.fixture(name="client_single") +def mock_controller_client_single(): """Mock a successful client.""" with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", update=False, ) as service_mock: - service_mock.return_value.get_data.return_value = {"name": CITY_1_NAME} + service_mock.return_value.search_places.return_value = [CITY_1] yield service_mock @@ -38,18 +91,29 @@ def mock_setup(): yield -@pytest.fixture(name="client_2") -def mock_controller_client_2(): +@pytest.fixture(name="client_multiple") +def mock_controller_client_multiple(): """Mock a successful client.""" with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", update=False, ) as service_mock: - service_mock.return_value.get_data.return_value = {"name": CITY_2_NAME} + service_mock.return_value.search_places.return_value = [CITY_2, CITY_3] yield service_mock -async def test_user(hass, client_1): +@pytest.fixture(name="client_empty") +def mock_controller_client_empty(): + """Mock a successful client.""" + with patch( + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", + update=False, + ) as service_mock: + service_mock.return_value.search_places.return_value = [] + yield service_mock + + +async def test_user(hass, client_single): """Test user config.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -57,32 +121,67 @@ async def test_user(hass, client_1): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - # test with all provided + # test with all provided with search returning only 1 place result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == CITY_1_NAME - assert result["title"] == CITY_1_NAME - assert result["data"][CONF_CITY] == CITY_1_POSTAL + assert result["result"].unique_id == f"{CITY_1_LAT}, {CITY_1_LON}" + assert result["title"] == f"{CITY_1}" + assert result["data"][CONF_LATITUDE] == str(CITY_1_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_1_LON) -async def test_import(hass, client_1): +async def test_user_list(hass, client_multiple): + """Test user config.""" + + # test with all provided with search returning more than 1 place + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_2_NAME}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "cities" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CITY: f"{CITY_3};{CITY_3_LAT};{CITY_3_LON}"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == f"{CITY_3_LAT}, {CITY_3_LON}" + assert result["title"] == f"{CITY_3}" + assert result["data"][CONF_LATITUDE] == str(CITY_3_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_3_LON) + + +async def test_import(hass, client_multiple): """Test import step.""" # import with all result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_1_POSTAL}, + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_2_NAME}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == CITY_1_NAME - assert result["title"] == CITY_1_NAME - assert result["data"][CONF_CITY] == CITY_1_POSTAL + assert result["result"].unique_id == f"{CITY_2_LAT}, {CITY_2_LON}" + assert result["title"] == f"{CITY_2}" + assert result["data"][CONF_LATITUDE] == str(CITY_2_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_2_LON) -async def test_abort_if_already_setup(hass, client_1): +async def test_search_failed(hass, client_empty): + """Test error displayed if no result in search.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_CITY: "empty"} + + +async def test_abort_if_already_setup(hass, client_single): """Test we abort if already setup.""" MockConfigEntry( - domain=DOMAIN, data={CONF_CITY: CITY_1_POSTAL}, unique_id=CITY_1_NAME + domain=DOMAIN, + data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON}, + unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", ).add_to_hass(hass) # Should fail, same CITY same postal code (import) @@ -100,39 +199,32 @@ async def test_abort_if_already_setup(hass, client_1): assert result["reason"] == "already_configured" -async def test_abort_if_already_setup_district(hass, client_2): - """Test we abort if already setup.""" - MockConfigEntry( - domain=DOMAIN, data={CONF_CITY: CITY_2_POSTAL_DISTRICT_1}, unique_id=CITY_2_NAME - ).add_to_hass(hass) - - # Should fail, same CITY different postal code (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4}, +async def test_options_flow(hass: HomeAssistantType): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON}, + unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + config_entry.add_to_hass(hass) - # Should fail, same CITY different postal code (flow) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4}, + assert config_entry.options == {} + + 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" + + # Default + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_MODE] == FORECAST_MODE_DAILY - -async def test_client_failed(hass): - """Test when we have errors during client fetch.""" - with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", - side_effect=meteofranceError(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "unknown" + # Manual + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_MODE: FORECAST_MODE_HOURLY}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_MODE] == FORECAST_MODE_HOURLY diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index 57ac39f8ee4..bece386a89e 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -1,4 +1,5 @@ """The test for the min/max sensor platform.""" +import statistics import unittest from homeassistant.const import ( @@ -27,6 +28,7 @@ class TestMinMaxSensor(unittest.TestCase): self.mean = round(sum(self.values) / self.count, 2) self.mean_1_digit = round(sum(self.values) / self.count, 1) self.mean_4_digits = round(sum(self.values) / self.count, 4) + self.median = round(statistics.median(self.values), 2) def teardown_method(self, method): """Stop everything that was started.""" @@ -58,6 +60,7 @@ class TestMinMaxSensor(unittest.TestCase): assert self.max == state.attributes.get("max_value") assert entity_ids[1] == state.attributes.get("max_entity_id") assert self.mean == state.attributes.get("mean") + assert self.median == state.attributes.get("median") def test_max_sensor(self): """Test the max sensor.""" @@ -85,6 +88,7 @@ class TestMinMaxSensor(unittest.TestCase): assert self.min == state.attributes.get("min_value") assert entity_ids[1] == state.attributes.get("max_entity_id") assert self.mean == state.attributes.get("mean") + assert self.median == state.attributes.get("median") def test_mean_sensor(self): """Test the mean sensor.""" @@ -112,6 +116,7 @@ class TestMinMaxSensor(unittest.TestCase): assert entity_ids[2] == state.attributes.get("min_entity_id") assert self.max == state.attributes.get("max_value") assert entity_ids[1] == state.attributes.get("max_entity_id") + assert self.median == state.attributes.get("median") def test_mean_1_digit_sensor(self): """Test the mean with 1-digit precision sensor.""" @@ -140,6 +145,7 @@ class TestMinMaxSensor(unittest.TestCase): assert entity_ids[2] == state.attributes.get("min_entity_id") assert self.max == state.attributes.get("max_value") assert entity_ids[1] == state.attributes.get("max_entity_id") + assert self.median == state.attributes.get("median") def test_mean_4_digit_sensor(self): """Test the mean with 1-digit precision sensor.""" @@ -168,6 +174,35 @@ class TestMinMaxSensor(unittest.TestCase): assert entity_ids[2] == state.attributes.get("min_entity_id") assert self.max == state.attributes.get("max_value") assert entity_ids[1] == state.attributes.get("max_entity_id") + assert self.median == state.attributes.get("median") + + def test_median_sensor(self): + """Test the median sensor.""" + config = { + "sensor": { + "platform": "min_max", + "name": "test_median", + "type": "median", + "entity_ids": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + } + } + + assert setup_component(self.hass, "sensor", config) + + entity_ids = config["sensor"]["entity_ids"] + + for entity_id, value in dict(zip(entity_ids, self.values)).items(): + self.hass.states.set(entity_id, value) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test_median") + + assert str(float(self.median)) == state.state + assert self.min == state.attributes.get("min_value") + assert entity_ids[2] == state.attributes.get("min_entity_id") + assert self.max == state.attributes.get("max_value") + assert entity_ids[1] == state.attributes.get("max_entity_id") + assert self.mean == state.attributes.get("mean") def test_not_enough_sensor_value(self): """Test that there is nothing done if not enough values available.""" @@ -193,6 +228,7 @@ class TestMinMaxSensor(unittest.TestCase): assert state.attributes.get("min_value") is None assert state.attributes.get("max_entity_id") is None assert state.attributes.get("max_value") is None + assert state.attributes.get("median") is None self.hass.states.set(entity_ids[1], self.values[1]) self.hass.block_till_done() @@ -295,3 +331,4 @@ class TestMinMaxSensor(unittest.TestCase): assert self.min == state.attributes.get("min_value") assert self.max == state.attributes.get("max_value") assert self.mean == state.attributes.get("mean") + assert self.median == state.attributes.get("median") diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 195c60d830c..bd38bca535b 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -406,3 +406,28 @@ async def test_webhook_camera_stream_stream_available_but_errors( webhook_json = await resp.json() assert webhook_json["hls_path"] is None assert webhook_json["mjpeg_path"] == "/api/camera_proxy_stream/camera.stream_camera" + + +async def test_webhook_handle_scan_tag(hass, create_registrations, webhook_client): + """Test that we can scan tags.""" + events = [] + + @callback + def store_event(event): + """Helepr to store events.""" + events.append(event) + + hass.bus.async_listen("tag_scanned", store_event) + + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={"type": "scan_tag", "data": {"tag_id": "mock-tag-id"}}, + ) + + assert resp.status == 200 + json = await resp.json() + assert json == {} + + assert len(events) == 1 + assert events[0].data["tag_id"] == "mock-tag-id" + assert events[0].data["device_id"] == "mock-device-id" diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index aa6452fd9c8..734e1fd552f 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -28,6 +28,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -42,6 +43,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import assert_setup_component, async_fire_mqtt_message from tests.components.alarm_control_panel import common @@ -575,6 +577,20 @@ async def test_discovery_update_alarm(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_alarm(hass, mqtt_mock, caplog): + """Test update of discovered alarm_control_panel.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) + config1["name"] = "Beer" + + data1 = json.dumps(config1) + with patch( + "homeassistant.components.mqtt.alarm_control_panel.MqttAlarm.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index b909a0592e0..c739f4378d1 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -26,6 +26,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -593,6 +594,20 @@ async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_binary_sensor(hass, mqtt_mock, caplog): + """Test update of discovered binary_sensor.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) + config1["name"] = "Beer" + + data1 = json.dumps(config1) + with patch( + "homeassistant.components.mqtt.binary_sensor.MqttBinarySensor.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, discovery_update + ) + + async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( hass, mqtt_mock, legacy_patchable_time, caplog ): diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 6869b530668..22f714fdcf7 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -16,6 +16,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -30,6 +31,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message DEFAULT_CONFIG = { @@ -153,14 +155,25 @@ async def test_discovery_update_camera(hass, mqtt_mock, caplog): entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] await async_start(hass, "homeassistant", entry) - data1 = '{ "name": "Beer",' ' "topic": "test_topic"}' - data2 = '{ "name": "Milk",' ' "topic": "test_topic"}' + data1 = '{ "name": "Beer", "topic": "test_topic"}' + data2 = '{ "name": "Milk", "topic": "test_topic"}' await help_test_discovery_update( hass, mqtt_mock, caplog, camera.DOMAIN, data1, data2 ) +async def test_discovery_update_unchanged_camera(hass, mqtt_mock, caplog): + """Test update of discovered camera.""" + data1 = '{ "name": "Beer", "topic": "test_topic"}' + with patch( + "homeassistant.components.mqtt.camera.MqttCamera.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, camera.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" @@ -168,7 +181,7 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): await async_start(hass, "homeassistant", entry) data1 = '{ "name": "Beer" }' - data2 = '{ "name": "Milk",' ' "topic": "test_topic"}' + data2 = '{ "name": "Milk", "topic": "test_topic"}' await help_test_discovery_broken( hass, mqtt_mock, caplog, camera.DOMAIN, data1, data2 diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 6a7bdf0b7e6..d60af211d71 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -34,6 +34,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -48,7 +49,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.async_mock import call +from tests.async_mock import call, patch from tests.common import async_fire_mqtt_message from tests.components.climate import common @@ -909,11 +910,22 @@ async def test_discovery_update_climate(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_climate(hass, mqtt_mock, caplog): + """Test update of discovered climate.""" + data1 = '{ "name": "Beer" }' + with patch( + "homeassistant.components.mqtt.climate.MqttClimate.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - data1 = '{ "name": "Beer",' ' "power_command_topic": "test_topic#" }' - data2 = '{ "name": "Milk", ' ' "power_command_topic": "test_topic" }' + data1 = '{ "name": "Beer", "power_command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "power_command_topic": "test_topic" }' await help_test_discovery_broken( hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 31566885a37..89bfde22d87 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -497,6 +497,29 @@ async def help_test_discovery_update(hass, mqtt_mock, caplog, domain, data1, dat assert state is None +async def help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, domain, data1, discovery_update +): + """Test update of discovered component without changes. + + This is a test helper for the MqttDiscoveryUpdate mixin. + """ + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + await async_start(hass, "homeassistant", entry) + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.beer") + assert state is not None + assert state.name == "Beer" + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) + await hass.async_block_till_done() + + assert not discovery_update.called + + async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, data2): """Test handling of bad discovery message.""" entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 581395b702a..5fbb772f949 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -185,10 +185,12 @@ async def test_option_flow(hass, mqtt_mock, mock_try_connection): result["flow_id"], user_input={ mqtt.CONF_DISCOVERY: True, + "birth_enable": True, "birth_topic": "ha_state/online", "birth_payload": "online", "birth_qos": 1, "birth_retain": True, + "will_enable": True, "will_topic": "ha_state/offline", "will_payload": "offline", "will_qos": 2, @@ -221,6 +223,69 @@ async def test_option_flow(hass, mqtt_mock, mock_try_connection): assert mqtt_mock.async_connect.call_count == 1 +async def test_disable_birth_will(hass, mqtt_mock, mock_try_connection): + """Test disabling birth and will.""" + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + } + + mqtt_mock.async_connect.reset_mock() + + 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"] == "broker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "options" + + await hass.async_block_till_done() + assert mqtt_mock.async_connect.call_count == 0 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_DISCOVERY: True, + "birth_enable": False, + "birth_topic": "ha_state/online", + "birth_payload": "online", + "birth_qos": 1, + "birth_retain": True, + "will_enable": False, + "will_topic": "ha_state/offline", + "will_payload": "offline", + "will_qos": 2, + "will_retain": True, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] is None + assert config_entry.data == { + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_BIRTH_MESSAGE: {}, + mqtt.CONF_WILL_MESSAGE: {}, + } + + await hass.async_block_till_done() + assert mqtt_mock.async_connect.call_count == 1 + + def get_default(schema, key): """Get default value for key in voluptuous schema.""" for k in schema.keys(): diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index c3f00badef8..f9036bcfa0f 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -38,6 +38,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -52,6 +53,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message DEFAULT_CONFIG = { @@ -1862,24 +1864,35 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_cover(hass, mqtt_mock, caplog): """Test removal of discovered cover.""" - data = '{ "name": "test",' ' "command_topic": "test_topic" }' + data = '{ "name": "test", "command_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, cover.DOMAIN, data) async def test_discovery_update_cover(hass, mqtt_mock, caplog): """Test update of discovered cover.""" - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_update( hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 ) +async def test_discovery_update_unchanged_cover(hass, mqtt_mock, caplog): + """Test update of discovered cover.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.cover.MqttCover.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, cover.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + data1 = '{ "name": "Beer", "command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken( hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 6c317e17989..c1388aeb1c1 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -338,6 +338,7 @@ async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): # Verify state is removed state = hass.states.get("sensor.mqtt_sensor") assert state is None + await hass.async_block_till_done() # Verify retained discovery topic has been cleared mqtt_mock.async_publish.assert_called_once_with( diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6114fe48ff4..e1801c5c15a 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -19,6 +19,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -33,6 +34,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message from tests.components.fan import common @@ -689,22 +691,33 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_fan(hass, mqtt_mock, caplog): """Test removal of discovered fan.""" - data = '{ "name": "test",' ' "command_topic": "test_topic" }' + data = '{ "name": "test", "command_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, fan.DOMAIN, data) async def test_discovery_update_fan(hass, mqtt_mock, caplog): """Test update of discovered fan.""" - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_update(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2) +async def test_discovery_update_unchanged_fan(hass, mqtt_mock, caplog): + """Test update of discovered fan.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.fan.MqttFan.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, fan.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 893c1b78f1e..aacea4e345e 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -31,6 +31,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -45,6 +46,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message from tests.components.vacuum import common @@ -643,6 +645,17 @@ async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_vacuum(hass, mqtt_mock, caplog): + """Test update of discovered vacuum.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.vacuum.schema_legacy.MqttVacuum.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 5fa8fa181e5..75d3e694838 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -170,6 +170,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -1450,6 +1451,21 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): + """Test update of discovered light.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.light.schema_basic.MqttLight.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7bb3763654e..54292aeeb7b 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -110,6 +110,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -1179,6 +1180,22 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): + """Test update of discovered light.""" + data1 = ( + '{ "name": "Beer",' + ' "schema": "json",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.light.schema_json.MqttLightJson.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index f0e226d2095..17b3332da40 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -47,6 +47,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -923,6 +924,24 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): + """Test update of discovered light.""" + data1 = ( + '{ "name": "Beer",' + ' "schema": "template",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + with patch( + "homeassistant.components.mqtt.light.schema_template.MqttLightTemplate.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index ff130077a95..cd37543d94e 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -20,6 +20,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -34,6 +35,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message DEFAULT_CONFIG = { @@ -382,6 +384,21 @@ async def test_discovery_update_lock(hass, mqtt_mock, caplog): await help_test_discovery_update(hass, mqtt_mock, caplog, LOCK_DOMAIN, data1, data2) +async def test_discovery_update_unchanged_lock(hass, mqtt_mock, caplog): + """Test update of discovered lock.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "command_topic" }' + ) + with patch( + "homeassistant.components.mqtt.lock.MqttLock.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, LOCK_DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 5ec5fccbe28..0d31b9f33f2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -24,6 +24,7 @@ from .test_common import ( help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_availability, + help_test_discovery_update_unchanged, help_test_entity_debug_info, help_test_entity_debug_info_max_messages, help_test_entity_debug_info_message, @@ -425,24 +426,35 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): """Test removal of discovered sensor.""" - data = '{ "name": "test",' ' "state_topic": "test_topic" }' + data = '{ "name": "test", "state_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, sensor.DOMAIN, data) async def test_discovery_update_sensor(hass, mqtt_mock, caplog): """Test update of discovered sensor.""" - data1 = '{ "name": "Beer",' ' "state_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' + data1 = '{ "name": "Beer", "state_topic": "test_topic" }' + data2 = '{ "name": "Milk", "state_topic": "test_topic" }' await help_test_discovery_update( hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2 ) +async def test_discovery_update_unchanged_sensor(hass, mqtt_mock, caplog): + """Test update of discovered sensor.""" + data1 = '{ "name": "Beer", "state_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.sensor.MqttSensor.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, sensor.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - data1 = '{ "name": "Beer",' ' "state_topic": "test_topic#" }' - data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' + data1 = '{ "name": "Beer", "state_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "state_topic": "test_topic" }' await help_test_discovery_broken( hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index c8ca7d3691b..fe410821395 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -41,6 +41,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -55,6 +56,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message from tests.components.vacuum import common @@ -410,24 +412,35 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog): """Test removal of discovered vacuum.""" - data = '{ "schema": "state", "name": "test",' ' "command_topic": "test_topic"}' + data = '{ "schema": "state", "name": "test", "command_topic": "test_topic"}' await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data) async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): """Test update of discovered vacuum.""" - data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic"}' - data2 = '{ "schema": "state", "name": "Milk",' ' "command_topic": "test_topic"}' + data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic"}' + data2 = '{ "schema": "state", "name": "Milk", "command_topic": "test_topic"}' await help_test_discovery_update( hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 ) +async def test_discovery_update_unchanged_vacuum(hass, mqtt_mock, caplog): + """Test update of discovered vacuum.""" + data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic"}' + with patch( + "homeassistant.components.mqtt.vacuum.schema_state.MqttStateVacuum.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic#"}' - data2 = '{ "schema": "state", "name": "Milk",' ' "command_topic": "test_topic"}' + data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic#"}' + data2 = '{ "schema": "state", "name": "Milk", "command_topic": "test_topic"}' await help_test_discovery_broken( hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 869a413eb6b..a6edb8d6f14 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -15,6 +15,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -320,6 +321,21 @@ async def test_discovery_update_switch(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_switch(hass, mqtt_mock, caplog): + """Test update of discovered switch.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.switch.MqttSwitch.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, switch.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 24668ea47e6..c6091e4d5e1 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -31,7 +31,7 @@ async def test_abort_if_existing_entry(hass): "netatmo", context={"source": config_entries.SOURCE_USER} ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" + assert result["reason"] == "single_instance_allowed" result = await hass.config_entries.flow.async_init( "netatmo", @@ -39,7 +39,7 @@ async def test_abort_if_existing_entry(hass): data={"host": "0.0.0.0", "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" + assert result["reason"] == "single_instance_allowed" async def test_full_flow(hass, aiohttp_client, aioclient_mock): @@ -108,11 +108,21 @@ async def test_option_flow(hass): """Test config flow options.""" valid_option = { "lat_ne": 32.91336, + "lon_ne": -117.187429, + "lat_sw": 32.83336, "lon_sw": -117.26743, "show_on_map": False, "area_name": "Home", - "lon_ne": -117.187429, - "lat_sw": 32.83336, + "mode": "avg", + } + + expected_result = { + "lat_ne": 32.9133601, + "lon_ne": -117.1874289, + "lat_sw": 32.8333601, + "lon_sw": -117.26742990000001, + "show_on_map": False, + "area_name": "Home", "mode": "avg", } @@ -145,4 +155,60 @@ async def test_option_flow(hass): ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert config_entry.options == {CONF_WEATHER_AREAS: {"Home": valid_option}} + for k, v in expected_result.items(): + assert config_entry.options[CONF_WEATHER_AREAS]["Home"][k] == v + + +async def test_option_flow_wrong_coordinates(hass): + """Test config flow options with mixed up coordinates.""" + valid_option = { + "lat_ne": 32.1234567, + "lon_ne": -117.2345678, + "lat_sw": 32.2345678, + "lon_sw": -117.1234567, + "show_on_map": False, + "area_name": "Home", + "mode": "avg", + } + + expected_result = { + "lat_ne": 32.2345678, + "lon_ne": -117.1234567, + "lat_sw": 32.1234567, + "lon_sw": -117.2345678, + "show_on_map": False, + "area_name": "Home", + "mode": "avg", + } + + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id=DOMAIN, data=VALID_CONFIG, options={}, + ) + config_entry.add_to_hass(hass) + + 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"] == "public_weather_areas" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_NEW_AREA: "Home"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "public_weather" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=valid_option + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "public_weather_areas" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + for k, v in expected_result.items(): + assert config_entry.options[CONF_WEATHER_AREAS]["Home"][k] == v diff --git a/tests/components/ovo_energy/__init__.py b/tests/components/ovo_energy/__init__.py new file mode 100644 index 00000000000..ea9402fcb0d --- /dev/null +++ b/tests/components/ovo_energy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OVO Energy integration.""" diff --git a/tests/components/ovo_energy/test_config_flow.py b/tests/components/ovo_energy/test_config_flow.py new file mode 100644 index 00000000000..73b2610cc7a --- /dev/null +++ b/tests/components/ovo_energy/test_config_flow.py @@ -0,0 +1,87 @@ +"""Test the OVO Energy config flow.""" +import aiohttp + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.ovo_energy.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.async_mock import patch + +FIXTURE_USER_INPUT = {CONF_USERNAME: "example@example.com", CONF_PASSWORD: "something"} + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + 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" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + 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" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_full_flow_implementation(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow works.""" + 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" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result2["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] diff --git a/tests/components/ozw/conftest.py b/tests/components/ozw/conftest.py index 14253e699f6..42947064c1e 100644 --- a/tests/components/ozw/conftest.py +++ b/tests/components/ozw/conftest.py @@ -27,6 +27,36 @@ def light_data_fixture(): return load_fixture("ozw/light_network_dump.csv") +@pytest.fixture(name="light_new_ozw_data", scope="session") +def light_new_ozw_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_new_ozw_network_dump.csv") + + +@pytest.fixture(name="light_no_rgb_data", scope="session") +def light_no_rgb_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_no_rgb_network_dump.csv") + + +@pytest.fixture(name="light_no_ww_data", scope="session") +def light_no_ww_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_no_ww_network_dump.csv") + + +@pytest.fixture(name="light_no_cw_data", scope="session") +def light_no_cw_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_no_cw_network_dump.csv") + + +@pytest.fixture(name="light_wc_data", scope="session") +def light_wc_only_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_wc_network_dump.csv") + + @pytest.fixture(name="cover_data", scope="session") def cover_data_fixture(): """Load cover MQTT data and return it.""" @@ -87,6 +117,28 @@ async def light_msg_fixture(hass): return message +@pytest.fixture(name="light_no_rgb_msg") +async def light_no_rgb_msg_fixture(hass): + """Return a mock MQTT msg with a light actuator message.""" + light_json = json.loads( + await hass.async_add_executor_job(load_fixture, "ozw/light_no_rgb.json") + ) + message = MQTTMessage(topic=light_json["topic"], payload=light_json["payload"]) + message.encode() + return message + + +@pytest.fixture(name="light_rgb_msg") +async def light_rgb_msg_fixture(hass): + """Return a mock MQTT msg with a light actuator message.""" + light_json = json.loads( + await hass.async_add_executor_job(load_fixture, "ozw/light_rgb.json") + ) + message = MQTTMessage(topic=light_json["topic"], payload=light_json["payload"]) + message.encode() + return message + + @pytest.fixture(name="switch_msg") async def switch_msg_fixture(hass): """Return a mock MQTT msg with a switch actuator message.""" diff --git a/tests/components/ozw/test_light.py b/tests/components/ozw/test_light.py index d485ca768c5..8f9892e37cd 100644 --- a/tests/components/ozw/test_light.py +++ b/tests/components/ozw/test_light.py @@ -4,7 +4,7 @@ from homeassistant.components.ozw.light import byte_to_zwave_brightness from .common import setup_ozw -async def test_light(hass, light_data, light_msg, sent_messages): +async def test_light(hass, light_data, light_msg, light_rgb_msg, sent_messages): """Test setting up config entry.""" receive_message = await setup_ozw(hass, fixture=light_data) @@ -149,3 +149,461 @@ async def test_light(hass, light_data, light_msg, sent_messages): state = hass.states.get("light.led_bulb_6_multi_colour_level") assert state is not None assert state.state == "off" + + # Test setting color_name + new_color = "blue" + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "color_name": new_color}, + blocking=True, + ) + assert len(sent_messages) == 9 + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#0000ff0000", "ValueIDKey": 659341335} + + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#0000ff0000" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["rgb_color"] == (0, 0, 255) + + # Test setting hs_color + new_color = [300, 70] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "hs_color": new_color}, + blocking=True, + ) + assert len(sent_messages) == 11 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#ff4cff0000", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#ff4cff0000" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["hs_color"] == (300.0, 70.196) + + # Test setting rgb_color + new_color = [255, 154, 0] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "rgb_color": new_color}, + blocking=True, + ) + assert len(sent_messages) == 13 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#ff99000000", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#ff99000000" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["rgb_color"] == (255, 153, 0) + + # Test setting xy_color + new_color = [0.52, 0.43] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "xy_color": new_color}, + blocking=True, + ) + assert len(sent_messages) == 15 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#ffbb370000", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#ffbb370000" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["xy_color"] == (0.519, 0.429) + + # Test setting color temp + new_color = 200 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "color_temp": new_color}, + blocking=True, + ) + assert len(sent_messages) == 17 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#00000037c8", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#00000037c8" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["color_temp"] == 200 + + # Test setting invalid color temp + new_color = 120 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "color_temp": new_color}, + blocking=True, + ) + assert len(sent_messages) == 19 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#00000000ff", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#00000000ff" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["color_temp"] == 153 + + +async def test_no_rgb_light(hass, light_no_rgb_data, light_no_rgb_msg, sent_messages): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_no_rgb_data) + + # Test loaded no RGBW support (dimmer only) + state = hass.states.get("light.master_bedroom_l_level") + assert state is not None + assert state.state == "off" + + # Turn on the light + new_brightness = 44 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.master_bedroom_l_level", "brightness": new_brightness}, + blocking=True, + ) + assert len(sent_messages) == 1 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == { + "Value": byte_to_zwave_brightness(new_brightness), + "ValueIDKey": 38371345, + } + + # Feedback on state + + light_no_rgb_msg.decode() + light_no_rgb_msg.payload["Value"] = byte_to_zwave_brightness(new_brightness) + light_no_rgb_msg.encode() + receive_message(light_no_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.master_bedroom_l_level") + assert state is not None + assert state.state == "on" + assert state.attributes["brightness"] == new_brightness + + +async def test_no_ww_light( + hass, light_no_ww_data, light_msg, light_rgb_msg, sent_messages +): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_no_ww_data) + + # Test loaded no ww support + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "off" + + # Turn on the light + white_color = 190 + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.led_bulb_6_multi_colour_level", + "white_value": white_color, + }, + blocking=True, + ) + assert len(sent_messages) == 2 + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#00000000be", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#00000000be" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["white_value"] == 190 + + +async def test_no_cw_light( + hass, light_no_cw_data, light_msg, light_rgb_msg, sent_messages +): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_no_cw_data) + + # Test loaded no cw support + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "off" + + # Turn on the light + white_color = 190 + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.led_bulb_6_multi_colour_level", + "white_value": white_color, + }, + blocking=True, + ) + assert len(sent_messages) == 2 + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#000000be00", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#000000be00" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["white_value"] == 190 + + +async def test_wc_light(hass, light_wc_data, light_msg, light_rgb_msg, sent_messages): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_wc_data) + + # Test loaded only white LED support + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "off" + + assert state.attributes["min_mireds"] == 153 + assert state.attributes["max_mireds"] == 370 + + # Turn on the light + new_color = 190 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "color_temp": new_color}, + blocking=True, + ) + assert len(sent_messages) == 2 + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#0000002bd4", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#0000002bd4" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["color_temp"] == 190 + + +async def test_new_ozw_light(hass, light_new_ozw_data, light_msg, sent_messages): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_new_ozw_data) + + # Test loaded only white LED support + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "off" + + # Test turning on with new duration (newer openzwave) + new_transition = 4180 + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.led_bulb_6_multi_colour_level", + "transition": new_transition, + }, + blocking=True, + ) + assert len(sent_messages) == 2 + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 4180, "ValueIDKey": 1407375551070225} + + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = 255 + light_msg.encode() + receive_message(light_msg) + await hass.async_block_till_done() + + # Test turning off with new duration (newer openzwave)(new max) + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.led_bulb_6_multi_colour_level"}, + blocking=True, + ) + assert len(sent_messages) == 4 + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 7621, "ValueIDKey": 1407375551070225} + + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 0, "ValueIDKey": 659128337} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = 0 + light_msg.encode() + receive_message(light_msg) + await hass.async_block_till_done() + + # Test turning on with new duration (newer openzwave)(factory default) + new_transition = 8000 + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.led_bulb_6_multi_colour_level", + "transition": new_transition, + }, + blocking=True, + ) + assert len(sent_messages) == 6 + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 6553, "ValueIDKey": 1407375551070225} + + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = 255 + light_msg.encode() + receive_message(light_msg) + await hass.async_block_till_done() diff --git a/tests/components/ozw/test_websocket_api.py b/tests/components/ozw/test_websocket_api.py new file mode 100644 index 00000000000..13ba6f2152c --- /dev/null +++ b/tests/components/ozw/test_websocket_api.py @@ -0,0 +1,58 @@ +"""Test OpenZWave Websocket API.""" + +from homeassistant.components.ozw.websocket_api import ID, NODE_ID, OZW_INSTANCE, TYPE + +from .common import setup_ozw + + +async def test_websocket_api(hass, generic_data, hass_ws_client): + """Test the ozw websocket api.""" + await setup_ozw(hass, fixture=generic_data) + client = await hass_ws_client(hass) + + # Test network status + await client.send_json({ID: 5, TYPE: "ozw/network_status"}) + msg = await client.receive_json() + result = msg["result"] + + assert result["state"] == "driverAllNodesQueried" + assert result[OZW_INSTANCE] == 1 + + # Test node status + await client.send_json({ID: 6, TYPE: "ozw/node_status", NODE_ID: 32}) + msg = await client.receive_json() + result = msg["result"] + + assert result[OZW_INSTANCE] == 1 + assert result[NODE_ID] == 32 + assert result["node_query_stage"] == "Complete" + assert result["is_zwave_plus"] + assert result["is_awake"] + assert not result["is_failed"] + assert result["node_baud_rate"] == 100000 + assert result["is_beaming"] + assert not result["is_flirs"] + assert result["is_routing"] + assert not result["is_securityv1"] + assert result["node_basic_string"] == "Routing Slave" + assert result["node_generic_string"] == "Binary Switch" + assert result["node_specific_string"] == "Binary Power Switch" + assert result["neighbors"] == [1, 33, 36, 37, 39] + + # Test node statistics + await client.send_json({ID: 7, TYPE: "ozw/node_statistics", NODE_ID: 39}) + msg = await client.receive_json() + result = msg["result"] + + assert result[OZW_INSTANCE] == 1 + assert result[NODE_ID] == 39 + assert result["send_count"] == 57 + assert result["sent_failed"] == 0 + assert result["retries"] == 1 + assert result["last_request_rtt"] == 26 + assert result["last_response_rtt"] == 38 + assert result["average_request_rtt"] == 29 + assert result["average_response_rtt"] == 37 + assert result["received_packets"] == 3594 + assert result["received_dup_packets"] == 12 + assert result["received_unsolicited"] == 3546 diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index b39bfdced2a..f487f413363 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -21,7 +21,7 @@ ZERO_DATA = { "domains_being_blocked": 0, "queries_cached": 0, "queries_forwarded": 0, - "status": 0, + "status": "disabled", "unique_clients": 0, "unique_domains": 0, } @@ -29,7 +29,7 @@ ZERO_DATA = { HOST = "1.2.3.4" PORT = 80 LOCATION = "location" -NAME = "name" +NAME = "Pi hole" API_KEY = "apikey" SSL = False VERIFY_SSL = True @@ -53,6 +53,8 @@ CONF_CONFIG_FLOW = { CONF_VERIFY_SSL: VERIFY_SSL, } +SWITCH_ENTITY_ID = "switch.pi_hole" + def _create_mocked_hole(raise_exception=False): mocked_hole = MagicMock() @@ -65,6 +67,10 @@ def _create_mocked_hole(raise_exception=False): return mocked_hole +def _patch_init_hole(mocked_hole): + return patch("homeassistant.components.pi_hole.Hole", return_value=mocked_hole) + + def _patch_config_flow_hole(mocked_hole): return patch( "homeassistant.components.pi_hole.config_flow.Hole", return_value=mocked_hole diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index 32b5b1ca146..07a9e08313a 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -1,5 +1,4 @@ """Test pi_hole config flow.""" -import copy import logging from homeassistant.components.pi_hole.const import DOMAIN @@ -13,7 +12,6 @@ from homeassistant.data_entry_flow import ( from . import ( CONF_CONFIG_FLOW, CONF_DATA, - CONF_HOST, NAME, _create_mocked_hole, _patch_config_flow_hole, @@ -54,16 +52,6 @@ async def test_flow_import(hass, caplog): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - # duplicated name - conf_data = copy.deepcopy(CONF_DATA) - conf_data[CONF_HOST] = "4.3.2.1" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf_data - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "duplicated_name" - assert len([x for x in caplog.records if x.levelno == logging.ERROR]) == 1 - async def test_flow_import_invalid(hass, caplog): """Test import flow with invalid server.""" @@ -103,15 +91,6 @@ async def test_flow_user(hass): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - # duplicated name - conf_data = copy.deepcopy(CONF_CONFIG_FLOW) - conf_data[CONF_HOST] = "4.3.2.1" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf_data - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "duplicated_name" - async def test_flow_user_invalid(hass): """Test user initialized flow with invalid server.""" diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index 088b56d75b9..1f3e2451895 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -1,18 +1,36 @@ """Test pi_hole component.""" +import logging -from homeassistant.components import pi_hole -from homeassistant.components.pi_hole.const import MIN_TIME_BETWEEN_UPDATES +from hole.exceptions import HoleError + +from homeassistant.components import pi_hole, switch +from homeassistant.components.pi_hole.const import ( + CONF_LOCATION, + DEFAULT_LOCATION, + DEFAULT_NAME, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + SERVICE_DISABLE, + SERVICE_DISABLE_ATTR_DURATION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util -from . import _create_mocked_hole, _patch_config_flow_hole +from . import ( + SWITCH_ENTITY_ID, + _create_mocked_hole, + _patch_config_flow_hole, + _patch_init_hole, +) -from tests.async_mock import patch -from tests.common import async_fire_time_changed - - -def _patch_init_hole(mocked_hole): - return patch("homeassistant.components.pi_hole.Hole", return_value=mocked_hole) +from tests.async_mock import AsyncMock +from tests.common import MockConfigEntry async def test_setup_minimal_config(hass): @@ -69,6 +87,9 @@ async def test_setup_minimal_config(hass): assert hass.states.get("sensor.pi_hole_domains_blocked").state == "0" assert hass.states.get("sensor.pi_hole_seen_clients").state == "0" + assert hass.states.get("binary_sensor.pi_hole").name == "Pi-Hole" + assert hass.states.get("binary_sensor.pi_hole").state == "off" + async def test_setup_name_config(hass): """Tests component setup with a custom name.""" @@ -88,6 +109,54 @@ async def test_setup_name_config(hass): ) +async def test_switch(hass, caplog): + """Test Pi-hole switch.""" + mocked_hole = _create_mocked_hole() + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + assert await async_setup_component( + hass, + pi_hole.DOMAIN, + {pi_hole.DOMAIN: [{"host": "pi.hole1", "api_key": "1"}]}, + ) + + await hass.async_block_till_done() + + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {"entity_id": SWITCH_ENTITY_ID}, + blocking=True, + ) + mocked_hole.enable.assert_called_once() + + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {"entity_id": SWITCH_ENTITY_ID}, + blocking=True, + ) + mocked_hole.disable.assert_called_once_with(True) + + # Failed calls + type(mocked_hole).enable = AsyncMock(side_effect=HoleError("Error1")) + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {"entity_id": SWITCH_ENTITY_ID}, + blocking=True, + ) + type(mocked_hole).disable = AsyncMock(side_effect=HoleError("Error2")) + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {"entity_id": SWITCH_ENTITY_ID}, + blocking=True, + ) + errors = [x for x in caplog.records if x.levelno == logging.ERROR] + assert errors[-2].message == "Unable to enable Pi-hole: Error1" + assert errors[-1].message == "Unable to disable Pi-hole: Error2" + + async def test_disable_service_call(hass): """Test disable service call with no Pi-hole named.""" mocked_hole = _create_mocked_hole() @@ -98,7 +167,7 @@ async def test_disable_service_call(hass): { pi_hole.DOMAIN: [ {"host": "pi.hole1", "api_key": "1"}, - {"host": "pi.hole2", "name": "Custom", "api_key": "2"}, + {"host": "pi.hole2", "name": "Custom"}, ] }, ) @@ -107,57 +176,35 @@ async def test_disable_service_call(hass): await hass.services.async_call( pi_hole.DOMAIN, - pi_hole.SERVICE_DISABLE, - {pi_hole.SERVICE_DISABLE_ATTR_DURATION: "00:00:01"}, + SERVICE_DISABLE, + {ATTR_ENTITY_ID: "all", SERVICE_DISABLE_ATTR_DURATION: "00:00:01"}, blocking=True, ) await hass.async_block_till_done() - assert mocked_hole.disable.call_count == 2 + mocked_hole.disable.assert_called_once_with(1) -async def test_enable_service_call(hass): - """Test enable service call with no Pi-hole named.""" +async def test_unload(hass): + """Test unload entities.""" + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_HOST: "pi.hole", + CONF_LOCATION: DEFAULT_LOCATION, + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + }, + ) + entry.add_to_hass(hass) mocked_hole = _create_mocked_hole() with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): - assert await async_setup_component( - hass, - pi_hole.DOMAIN, - { - pi_hole.DOMAIN: [ - {"host": "pi.hole1", "api_key": "1"}, - {"host": "pi.hole2", "name": "Custom", "api_key": "2"}, - ] - }, - ) - + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.entry_id in hass.data[pi_hole.DOMAIN] - await hass.services.async_call( - pi_hole.DOMAIN, pi_hole.SERVICE_ENABLE, {}, blocking=True - ) - - await hass.async_block_till_done() - - assert mocked_hole.enable.call_count == 2 - - -async def test_update_coordinator(hass): - """Test update coordinator.""" - mocked_hole = _create_mocked_hole() - sensor_entity_id = "sensor.pi_hole_ads_blocked_today" - with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): - assert await async_setup_component( - hass, pi_hole.DOMAIN, {pi_hole.DOMAIN: [{"host": "pi.hole"}]} - ) - await hass.async_block_till_done() - assert mocked_hole.get_data.call_count == 3 - assert hass.states.get(sensor_entity_id).state == "0" - - mocked_hole.data["ads_blocked_today"] = 1 - utcnow = dt_util.utcnow() - async_fire_time_changed(hass, utcnow + MIN_TIME_BETWEEN_UPDATES) - await hass.async_block_till_done() - assert mocked_hole.get_data.call_count == 4 - assert hass.states.get(sensor_entity_id).state == "1" + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.entry_id not in hass.data[pi_hole.DOMAIN] diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index 82682ea0ac2..b031aff25cd 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -1,4 +1,6 @@ """Tests for Plex player playback methods/services.""" +from plexapi.exceptions import NotFound + from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, @@ -52,7 +54,7 @@ async def test_sonos_playback(hass): True, ) - # Test success with dict + # Test success with plex_key with patch.object( hass.components.sonos, "get_coordinator_name", @@ -69,7 +71,7 @@ async def test_sonos_playback(hass): True, ) - # Test success with plex_key + # Test success with dict with patch.object( hass.components.sonos, "get_coordinator_name", @@ -86,6 +88,23 @@ async def test_sonos_playback(hass): True, ) + # Test media lookup failure + with patch.object( + hass.components.sonos, + "get_coordinator_name", + return_value="media_player.sonos_kitchen", + ), patch.object(mock_plex_server, "fetchItem", side_effect=NotFound): + assert await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_ON_SONOS, + { + ATTR_ENTITY_ID: "media_player.sonos_kitchen", + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: "999", + }, + True, + ) + # Test invalid Plex server requested with patch.object( hass.components.sonos, diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 04dc67bdbe8..7b27bdf2f39 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -50,8 +50,7 @@ async def test_invalid_password(hass): flow.context = {"source": SOURCE_USER} with patch( - "homeassistant.components.rainmachine.config_flow.login", - side_effect=RainMachineError, + "regenmaschine.client.Client.load_local", side_effect=RainMachineError, ): result = await flow.async_step_user(user_input=conf) assert result["errors"] == {CONF_PASSWORD: "invalid_credentials"} @@ -84,7 +83,7 @@ async def test_step_import(hass): flow.context = {"source": SOURCE_USER} with patch( - "homeassistant.components.rainmachine.config_flow.login", return_value=True, + "regenmaschine.client.Client.load_local", return_value=True, ): result = await flow.async_step_import(import_config=conf) @@ -115,7 +114,7 @@ async def test_step_user(hass): flow.context = {"source": SOURCE_USER} with patch( - "homeassistant.components.rainmachine.config_flow.login", return_value=True, + "regenmaschine.client.Client.load_local", return_value=True, ): result = await flow.async_step_user(user_input=conf) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 46db4782628..a4f70fc09a6 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -17,14 +17,18 @@ from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import Events, RecorderRuns, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import MATCH_ALL, STATE_LOCKED, STATE_UNLOCKED -from homeassistant.core import ATTR_NOW, EVENT_TIME_CHANGED, Context, callback +from homeassistant.core import Context, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import wait_recording_done from tests.async_mock import patch -from tests.common import get_test_home_assistant, init_recorder_component +from tests.common import ( + async_fire_time_changed, + get_test_home_assistant, + init_recorder_component, +) class TestRecorder(unittest.TestCase): @@ -335,15 +339,15 @@ def test_auto_purge(hass_recorder): tz = dt_util.get_time_zone("Europe/Copenhagen") dt_util.set_default_time_zone(tz) - test_time = tz.localize(datetime(2020, 1, 1, 4, 12, 0)) + now = dt_util.utcnow() + test_time = tz.localize(datetime(now.year + 1, 1, 1, 4, 12, 0)) + async_fire_time_changed(hass, test_time) with patch( "homeassistant.components.recorder.purge.purge_old_data", return_value=True ) as purge_old_data: for delta in (-1, 0, 1): - hass.bus.fire( - EVENT_TIME_CHANGED, {ATTR_NOW: test_time + timedelta(seconds=delta)} - ) + async_fire_time_changed(hass, test_time + timedelta(seconds=delta)) hass.block_till_done() hass.data[DATA_INSTANCE].block_till_done() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 6a4126e76fd..56f1e069a61 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -1,8 +1,10 @@ """Test util methods.""" +import os + import pytest from homeassistant.components.recorder import util -from homeassistant.components.recorder.const import DATA_INSTANCE +from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX from tests.async_mock import MagicMock, patch from tests.common import get_test_home_assistant, init_recorder_component @@ -60,3 +62,31 @@ def test_recorder_bad_execute(hass_recorder): util.execute((mck1,), to_native=True) assert e_mock.call_count == 2 + + +def test_validate_or_move_away_sqlite_database(hass, tmpdir, caplog): + """Ensure a malformed sqlite database is moved away.""" + + test_dir = tmpdir.mkdir("test_validate_or_move_away_sqlite_database") + test_db_file = f"{test_dir}/broken.db" + dburl = f"{SQLITE_URL_PREFIX}{test_db_file}" + + util.validate_sqlite_database(test_db_file) is True + + assert os.path.exists(test_db_file) is True + assert util.validate_or_move_away_sqlite_database(dburl) is True + + _corrupt_db_file(test_db_file) + + assert util.validate_or_move_away_sqlite_database(dburl) is False + + assert "corrupt or malformed" in caplog.text + + assert util.validate_or_move_away_sqlite_database(dburl) is True + + +def _corrupt_db_file(test_db_file): + """Corrupt an sqlite3 database file.""" + f = open(test_db_file, "a") + f.write("I am a corrupt db") + f.close() diff --git a/tests/components/rfxtrx/__init__.py b/tests/components/rfxtrx/__init__.py index 53d53664591..81b2db8f4df 100644 --- a/tests/components/rfxtrx/__init__.py +++ b/tests/components/rfxtrx/__init__.py @@ -1,14 +1 @@ """Tests for the rfxtrx component.""" -from homeassistant.components import rfxtrx - - -async def _signal_event(hass, packet_id): - event = rfxtrx.get_rfx_object(packet_id) - - await hass.async_add_executor_job( - hass.data[rfxtrx.DATA_RFXOBJECT].event_callback, event, - ) - - await hass.async_block_till_done() - await hass.async_block_till_done() - return event diff --git a/tests/components/rfxtrx/conftest.py b/tests/components/rfxtrx/conftest.py index eba322de5a3..94d440312b4 100644 --- a/tests/components/rfxtrx/conftest.py +++ b/tests/components/rfxtrx/conftest.py @@ -1,12 +1,61 @@ """Common test tools.""" -from unittest import mock +from datetime import timedelta import pytest +from homeassistant.components import rfxtrx +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.async_mock import patch +from tests.common import async_fire_time_changed + @pytest.fixture(autouse=True, name="rfxtrx") -async def rfxtrx(hass): +async def rfxtrx_fixture(hass): """Fixture that cleans up threads from integration.""" - with mock.patch("RFXtrx.Connect") as connect, mock.patch("RFXtrx.DummyTransport2"): - yield connect.return_value + with patch("RFXtrx.Connect") as connect, patch("RFXtrx.DummyTransport2"): + rfx = connect.return_value + + async def _signal_event(packet_id): + event = rfxtrx.get_rfx_object(packet_id) + await hass.async_add_executor_job( + rfx.event_callback, event, + ) + + await hass.async_block_till_done() + await hass.async_block_till_done() + return event + + rfx.signal = _signal_event + + yield rfx + + +@pytest.fixture(name="rfxtrx_automatic") +async def rfxtrx_automatic_fixture(hass, rfxtrx): + """Fixture that starts up with automatic additions.""" + + assert await async_setup_component( + hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}}, + ) + await hass.async_block_till_done() + await hass.async_start() + yield rfxtrx + + +@pytest.fixture +async def timestep(hass): + """Step system time forward.""" + + with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: + mock_utcnow.return_value = utcnow() + + async def delay(seconds): + """Trigger delay in system.""" + mock_utcnow.return_value += timedelta(seconds=seconds) + async_fire_time_changed(hass, mock_utcnow.return_value) + await hass.async_block_till_done() + + yield delay diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index ad04a0763f2..11efcfb6510 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -1,16 +1,22 @@ """The tests for the Rfxtrx sensor platform.""" -from datetime import timedelta - import pytest from homeassistant.components.rfxtrx.const import ATTR_EVENT from homeassistant.core import State from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow -from . import _signal_event +from tests.common import mock_restore_cache -from tests.common import async_fire_time_changed, mock_restore_cache +EVENT_SMOKE_DETECTOR_PANIC = "08200300a109000670" +EVENT_SMOKE_DETECTOR_NO_PANIC = "08200300a109000770" + +EVENT_MOTION_DETECTOR_MOTION = "08200100a109000470" +EVENT_MOTION_DETECTOR_NO_MOTION = "08200100a109000570" + +EVENT_LIGHT_DETECTOR_LIGHT = "08200100a109001570" +EVENT_LIGHT_DETECTOR_DARK = "08200100a109001470" + +EVENT_AC_118CDEA_2_ON = "0b1100100118cdea02010f70" async def test_one(hass, rfxtrx): @@ -54,11 +60,11 @@ async def test_one_pt2262(hass, rfxtrx): assert state.state == "off" # probably aught to be unknown assert state.attributes.get("friendly_name") == "PT2262 22670e" - await _signal_event(hass, "0913000022670e013970") + await rfxtrx.signal("0913000022670e013970") state = hass.states.get("binary_sensor.pt2262_22670e") assert state.state == "on" - await _signal_event(hass, "09130000226707013d70") + await rfxtrx.signal("09130000226707013d70") state = hass.states.get("binary_sensor.pt2262_22670e") assert state.state == "off" @@ -118,38 +124,53 @@ async def test_several(hass, rfxtrx): assert state.attributes.get("friendly_name") == "AC 1118cdea:2" -async def test_discover(hass, rfxtrx): +async def test_discover(hass, rfxtrx_automatic): """Test with discovery.""" + rfxtrx = rfxtrx_automatic + + await rfxtrx.signal("0b1100100118cdea02010f70") + state = hass.states.get("binary_sensor.ac_118cdea_2") + assert state + assert state.state == "on" + + await rfxtrx.signal("0b1100100118cdeb02010f70") + state = hass.states.get("binary_sensor.ac_118cdeb_2") + assert state + assert state.state == "on" + + +async def test_off_delay_restore(hass, rfxtrx): + """Make sure binary sensor restore as off, if off delay is active.""" + mock_restore_cache( + hass, + [ + State( + "binary_sensor.ac_118cdea_2", + "on", + attributes={ATTR_EVENT: EVENT_AC_118CDEA_2_ON}, + ) + ], + ) + assert await async_setup_component( hass, "rfxtrx", { "rfxtrx": { "device": "abcd", - "automatic_add": True, - "devices": { - "0b1100cd0213c7f230010f71": {}, - "0b1100100118cdea02010f70": {}, - "0b1100101118cdea02010f70": {}, - }, + "devices": {EVENT_AC_118CDEA_2_ON: {"off_delay": 5}}, } }, ) await hass.async_block_till_done() await hass.async_start() - await _signal_event(hass, "0b1100100118cdea02010f70") state = hass.states.get("binary_sensor.ac_118cdea_2") assert state - assert state.state == "on" - - await _signal_event(hass, "0b1100100118cdeb02010f70") - state = hass.states.get("binary_sensor.ac_118cdeb_2") - assert state - assert state.state == "on" + assert state.state == "off" -async def test_off_delay(hass, rfxtrx): +async def test_off_delay(hass, rfxtrx, timestep): """Test with discovery.""" assert await async_setup_component( hass, @@ -168,21 +189,76 @@ async def test_off_delay(hass, rfxtrx): assert state assert state.state == "off" - await _signal_event(hass, "0b1100100118cdea02010f70") + await rfxtrx.signal("0b1100100118cdea02010f70") state = hass.states.get("binary_sensor.ac_118cdea_2") assert state assert state.state == "on" - base_time = utcnow() - - async_fire_time_changed(hass, base_time + timedelta(seconds=4)) - await hass.async_block_till_done() + await timestep(4) state = hass.states.get("binary_sensor.ac_118cdea_2") assert state assert state.state == "on" - async_fire_time_changed(hass, base_time + timedelta(seconds=6)) - await hass.async_block_till_done() + await timestep(4) state = hass.states.get("binary_sensor.ac_118cdea_2") assert state assert state.state == "off" + + await rfxtrx.signal("0b1100100118cdea02010f70") + state = hass.states.get("binary_sensor.ac_118cdea_2") + assert state + assert state.state == "on" + + await timestep(3) + await rfxtrx.signal("0b1100100118cdea02010f70") + + await timestep(4) + state = hass.states.get("binary_sensor.ac_118cdea_2") + assert state + assert state.state == "on" + + await timestep(4) + state = hass.states.get("binary_sensor.ac_118cdea_2") + assert state + assert state.state == "off" + + +async def test_panic(hass, rfxtrx_automatic): + """Test panic entities.""" + rfxtrx = rfxtrx_automatic + + entity_id = "binary_sensor.kd101_smoke_detector_a10900_32" + + await rfxtrx.signal(EVENT_SMOKE_DETECTOR_PANIC) + assert hass.states.get(entity_id).state == "on" + assert hass.states.get(entity_id).attributes.get("device_class") == "smoke" + + await rfxtrx.signal(EVENT_SMOKE_DETECTOR_NO_PANIC) + assert hass.states.get(entity_id).state == "off" + + +async def test_motion(hass, rfxtrx_automatic): + """Test motion entities.""" + rfxtrx = rfxtrx_automatic + + entity_id = "binary_sensor.x10_security_motion_detector_a10900_32" + + await rfxtrx.signal(EVENT_MOTION_DETECTOR_MOTION) + assert hass.states.get(entity_id).state == "on" + assert hass.states.get(entity_id).attributes.get("device_class") == "motion" + + await rfxtrx.signal(EVENT_MOTION_DETECTOR_NO_MOTION) + assert hass.states.get(entity_id).state == "off" + + +async def test_light(hass, rfxtrx_automatic): + """Test light entities.""" + rfxtrx = rfxtrx_automatic + + entity_id = "binary_sensor.x10_security_motion_detector_a10900_32" + + await rfxtrx.signal(EVENT_LIGHT_DETECTOR_LIGHT) + assert hass.states.get(entity_id).state == "on" + + await rfxtrx.signal(EVENT_LIGHT_DETECTOR_DARK) + assert hass.states.get(entity_id).state == "off" diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py index 73c3cb9cc27..ce4838cebf1 100644 --- a/tests/components/rfxtrx/test_cover.py +++ b/tests/components/rfxtrx/test_cover.py @@ -6,8 +6,6 @@ import pytest from homeassistant.core import State from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.common import mock_restore_cache @@ -103,20 +101,16 @@ async def test_several_covers(hass, rfxtrx): assert state.attributes.get("friendly_name") == "RollerTrol 009ba8:1" -async def test_discover_covers(hass, rfxtrx): +async def test_discover_covers(hass, rfxtrx_automatic): """Test with discovery of covers.""" - assert await async_setup_component( - hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}} - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic - await _signal_event(hass, "0a140002f38cae010f0070") + await rfxtrx.signal("0a140002f38cae010f0070") state = hass.states.get("cover.lightwaverf_siemens_f38cae_1") assert state assert state.state == "open" - await _signal_event(hass, "0a1400adf394ab020e0060") + await rfxtrx.signal("0a1400adf394ab020e0060") state = hass.states.get("cover.lightwaverf_siemens_f394ab_2") assert state assert state.state == "open" diff --git a/tests/components/rfxtrx/test_init.py b/tests/components/rfxtrx/test_init.py index 2a091525328..abe7c3c0441 100644 --- a/tests/components/rfxtrx/test_init.py +++ b/tests/components/rfxtrx/test_init.py @@ -1,11 +1,9 @@ """The tests for the Rfxtrx component.""" -from homeassistant.components import rfxtrx +from homeassistant.components.rfxtrx.const import EVENT_RFXTRX_EVENT from homeassistant.core import callback from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.async_mock import call @@ -55,7 +53,7 @@ async def test_invalid_config(hass): ) -async def test_fire_event(hass): +async def test_fire_event(hass, rfxtrx): """Test fire event.""" assert await async_setup_component( hass, @@ -66,8 +64,8 @@ async def test_fire_event(hass): + "-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", "automatic_add": True, "devices": { - "0b1100cd0213c7f210010f51": {rfxtrx.CONF_FIRE_EVENT: True}, - "0716000100900970": {rfxtrx.CONF_FIRE_EVENT: True}, + "0b1100cd0213c7f210010f51": {"fire_event": True}, + "0716000100900970": {"fire_event": True}, }, } }, @@ -83,10 +81,10 @@ async def test_fire_event(hass): assert event.event_type == "rfxtrx_event" calls.append(event.data) - hass.bus.async_listen(rfxtrx.const.EVENT_RFXTRX_EVENT, record_event) + hass.bus.async_listen(EVENT_RFXTRX_EVENT, record_event) - await _signal_event(hass, "0b1100cd0213c7f210010f51") - await _signal_event(hass, "0716000100900970") + await rfxtrx.signal("0b1100cd0213c7f210010f51") + await rfxtrx.signal("0716000100900970") assert calls == [ { diff --git a/tests/components/rfxtrx/test_light.py b/tests/components/rfxtrx/test_light.py index b96dec95e6e..f6f056fa16a 100644 --- a/tests/components/rfxtrx/test_light.py +++ b/tests/components/rfxtrx/test_light.py @@ -7,8 +7,6 @@ from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.core import State from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.common import mock_restore_cache @@ -125,6 +123,7 @@ async def test_several_lights(hass, rfxtrx): }, ) await hass.async_block_till_done() + await hass.async_start() state = hass.states.get("light.ac_213c7f2_48") assert state @@ -141,6 +140,22 @@ async def test_several_lights(hass, rfxtrx): assert state.state == "off" assert state.attributes.get("friendly_name") == "AC 1118cdea:2" + await rfxtrx.signal("0b1100cd0213c7f230010f71") + state = hass.states.get("light.ac_213c7f2_48") + assert state + assert state.state == "on" + + await rfxtrx.signal("0b1100cd0213c7f230000f71") + state = hass.states.get("light.ac_213c7f2_48") + assert state + assert state.state == "off" + + await rfxtrx.signal("0b1100cd0213c7f230020f71") + state = hass.states.get("light.ac_213c7f2_48") + assert state + assert state.state == "on" + assert state.attributes.get("brightness") == 255 + @pytest.mark.parametrize("repetitions", [1, 3]) async def test_repetitions(hass, rfxtrx, repetitions): @@ -167,21 +182,17 @@ async def test_repetitions(hass, rfxtrx, repetitions): assert rfxtrx.transport.send.call_count == repetitions -async def test_discover_light(hass, rfxtrx): +async def test_discover_light(hass, rfxtrx_automatic): """Test with discovery of lights.""" - assert await async_setup_component( - hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}}, - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic - await _signal_event(hass, "0b11009e00e6116202020070") + await rfxtrx.signal("0b11009e00e6116202020070") state = hass.states.get("light.ac_0e61162_2") assert state assert state.state == "on" assert state.attributes.get("friendly_name") == "AC 0e61162:2" - await _signal_event(hass, "0b1100120118cdea02020070") + await rfxtrx.signal("0b1100120118cdea02020070") state = hass.states.get("light.ac_118cdea_2") assert state assert state.state == "on" diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 3a797f4168d..d87cf1a71e2 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -6,8 +6,6 @@ from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE from homeassistant.core import State from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.common import mock_restore_cache @@ -150,16 +148,12 @@ async def test_several_sensors(hass, rfxtrx): assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE -async def test_discover_sensor(hass, rfxtrx): +async def test_discover_sensor(hass, rfxtrx_automatic): """Test with discovery of sensor.""" - assert await async_setup_component( - hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}}, - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic # 1 - await _signal_event(hass, "0a520801070100b81b0279") + await rfxtrx.signal("0a520801070100b81b0279") base_id = "sensor.wt260_wt260h_wt440h_wt450_wt450h_07_01" state = hass.states.get(f"{base_id}_humidity") @@ -188,7 +182,7 @@ async def test_discover_sensor(hass, rfxtrx): assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE # 2 - await _signal_event(hass, "0a52080405020095240279") + await rfxtrx.signal("0a52080405020095240279") base_id = "sensor.wt260_wt260h_wt440h_wt450_wt450h_05_02" state = hass.states.get(f"{base_id}_humidity") @@ -217,7 +211,7 @@ async def test_discover_sensor(hass, rfxtrx): assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE # 1 Update - await _signal_event(hass, "0a52085e070100b31b0279") + await rfxtrx.signal("0a52085e070100b31b0279") base_id = "sensor.wt260_wt260h_wt440h_wt450_wt450h_07_01" state = hass.states.get(f"{base_id}_humidity") @@ -278,8 +272,8 @@ async def test_update_of_sensors(hass, rfxtrx): assert state assert state.state == "unknown" - await _signal_event(hass, "0a520802060101ff0f0269") - await _signal_event(hass, "0a52080705020085220269") + await rfxtrx.signal("0a520802060101ff0f0269") + await rfxtrx.signal("0a52080705020085220269") state = hass.states.get("sensor.wt260_wt260h_wt440h_wt450_wt450h_05_02_temperature") assert state @@ -292,3 +286,60 @@ async def test_update_of_sensors(hass, rfxtrx): state = hass.states.get("sensor.wt260_wt260h_wt440h_wt450_wt450h_06_01_humidity") assert state assert state.state == "15" + + +async def test_rssi_sensor(hass, rfxtrx): + """Test with 1 sensor.""" + assert await async_setup_component( + hass, + "rfxtrx", + { + "rfxtrx": { + "device": "abcd", + "devices": { + "0913000022670e013b70": { + "data_bits": 4, + "command_on": 0xE, + "command_off": 0x7, + }, + "0b1100cd0213c7f230010f71": {}, + }, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + + state = hass.states.get("sensor.pt2262_22670e_rssi_numeric") + assert state + assert state.state == "unknown" + assert state.attributes.get("friendly_name") == "PT2262 22670e Rssi numeric" + assert state.attributes.get("unit_of_measurement") == "dBm" + + state = hass.states.get("sensor.ac_213c7f2_48_rssi_numeric") + assert state + assert state.state == "unknown" + assert state.attributes.get("friendly_name") == "AC 213c7f2:48 Rssi numeric" + assert state.attributes.get("unit_of_measurement") == "dBm" + + await rfxtrx.signal("0913000022670e013b70") + await rfxtrx.signal("0b1100cd0213c7f230010f71") + + state = hass.states.get("sensor.pt2262_22670e_rssi_numeric") + assert state + assert state.state == "-64" + + state = hass.states.get("sensor.ac_213c7f2_48_rssi_numeric") + assert state + assert state.state == "-64" + + await rfxtrx.signal("0913000022670e013b60") + await rfxtrx.signal("0b1100cd0213c7f230010f61") + + state = hass.states.get("sensor.pt2262_22670e_rssi_numeric") + assert state + assert state.state == "-72" + + state = hass.states.get("sensor.ac_213c7f2_48_rssi_numeric") + assert state + assert state.state == "-72" diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 22f7a73c77c..3256f303708 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -6,10 +6,11 @@ import pytest from homeassistant.core import State from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.common import mock_restore_cache +EVENT_RFY_ENABLE_SUN_AUTO = "081a00000301010113" +EVENT_RFY_DISABLE_SUN_AUTO = "081a00000301010114" + async def test_one_switch(hass, rfxtrx): """Test with 1 switch.""" @@ -122,20 +123,31 @@ async def test_repetitions(hass, rfxtrx, repetitions): assert rfxtrx.transport.send.call_count == repetitions -async def test_discover_switch(hass, rfxtrx): +async def test_discover_switch(hass, rfxtrx_automatic): """Test with discovery of switches.""" - assert await async_setup_component( - hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}}, - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic - await _signal_event(hass, "0b1100100118cdea02010f70") + await rfxtrx.signal("0b1100100118cdea02010f70") state = hass.states.get("switch.ac_118cdea_2") assert state assert state.state == "on" - await _signal_event(hass, "0b1100100118cdeb02010f70") + await rfxtrx.signal("0b1100100118cdeb02010f70") state = hass.states.get("switch.ac_118cdeb_2") assert state assert state.state == "on" + + +async def test_discover_rfy_sun_switch(hass, rfxtrx_automatic): + """Test with discovery of switches.""" + rfxtrx = rfxtrx_automatic + + await rfxtrx.signal(EVENT_RFY_DISABLE_SUN_AUTO) + state = hass.states.get("switch.rfy_030101_1") + assert state + assert state.state == "off" + + await rfxtrx.signal(EVENT_RFY_ENABLE_SUN_AUTO) + state = hass.states.get("switch.rfy_030101_1") + assert state + assert state.state == "on" diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index 76f81ea72df..7019d22fac8 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -6,7 +6,7 @@ from typing import Tuple import unittest from homeassistant.components import shell_command -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component, setup_component from tests.async_mock import Mock, patch from tests.common import get_test_home_assistant @@ -178,3 +178,23 @@ class TestShellCommand(unittest.TestCase): self.hass.block_till_done() assert mock_output.call_count == 1 assert test_phrase.encode() + b"\n" == mock_output.call_args_list[0][0][-1] + + +async def test_do_no_run_forever(hass, caplog): + """Test subprocesses terminate after the timeout.""" + + with patch.object(shell_command, "COMMAND_TIMEOUT", 0.001): + assert await async_setup_component( + hass, + shell_command.DOMAIN, + {shell_command.DOMAIN: {"test_service": "sleep 10000"}}, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + shell_command.DOMAIN, "test_service", blocking=True + ) + await hass.async_block_till_done() + + assert "Timed out" in caplog.text + assert "sleep 10000" in caplog.text diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py index 458e5f8ce27..42215def82f 100644 --- a/tests/components/smartthings/test_smartapp.py +++ b/tests/components/smartthings/test_smartapp.py @@ -1,7 +1,7 @@ """Tests for the smartapp module.""" from uuid import uuid4 -from pysmartthings import AppEntity, Capability +from pysmartthings import CAPABILITIES, AppEntity, Capability from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings.const import ( @@ -89,7 +89,7 @@ async def test_smartapp_webhook(hass): async def test_smartapp_sync_subscriptions( hass, smartthings_mock, device_factory, subscription_factory ): - """Test synchronization adds and removes.""" + """Test synchronization adds and removes and ignores unused.""" smartthings_mock.subscriptions.return_value = [ subscription_factory(Capability.thermostat), subscription_factory(Capability.switch), @@ -98,7 +98,7 @@ async def test_smartapp_sync_subscriptions( devices = [ device_factory("", [Capability.battery, "ping"]), device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), + device_factory("", [Capability.switch, Capability.execute]), ] await smartapp.smartapp_sync_subscriptions( @@ -134,6 +134,25 @@ async def test_smartapp_sync_subscriptions_up_to_date( assert smartthings_mock.create_subscription.call_count == 0 +async def test_smartapp_sync_subscriptions_limit_warning( + hass, smartthings_mock, device_factory, subscription_factory, caplog +): + """Test synchronization over the limit logs a warning.""" + smartthings_mock.subscriptions.return_value = [] + devices = [ + device_factory("", CAPABILITIES), + ] + + await smartapp.smartapp_sync_subscriptions( + hass, str(uuid4()), str(uuid4()), str(uuid4()), devices + ) + + assert ( + "Some device attributes may not receive push updates and there may be " + "subscription creation failures" in caplog.text + ) + + async def test_smartapp_sync_subscriptions_handles_exceptions( hass, smartthings_mock, device_factory, subscription_factory ): diff --git a/tests/components/spider/__init__.py b/tests/components/spider/__init__.py new file mode 100644 index 00000000000..d145f4efc09 --- /dev/null +++ b/tests/components/spider/__init__.py @@ -0,0 +1 @@ +"""Tests for the Spider component.""" diff --git a/tests/components/spider/test_config_flow.py b/tests/components/spider/test_config_flow.py new file mode 100644 index 00000000000..5c2c074027f --- /dev/null +++ b/tests/components/spider/test_config_flow.py @@ -0,0 +1,100 @@ +"""Tests for the Spider config flow.""" +import pytest + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.spider.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry + +USERNAME = "spider-username" +PASSWORD = "spider-password" + +SPIDER_USER_DATA = { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, +} + + +@pytest.fixture(name="spider") +def spider_fixture() -> Mock: + """Patch libraries.""" + with patch("homeassistant.components.spider.config_flow.SpiderApi") as spider: + yield spider + + +async def test_user(hass, spider): + """Test user config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.spider.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.spider.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert not result["result"].unique_id + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass, spider): + """Test import step.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.spider.async_setup", return_value=True, + ) as mock_setup, patch( + "homeassistant.components.spider.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=SPIDER_USER_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert not result["result"].unique_id + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_if_already_setup(hass, spider): + """Test we abort if Spider is already setup.""" + MockConfigEntry(domain=DOMAIN, data=SPIDER_USER_DATA).add_to_hass(hass) + + # Should fail, config exist (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + # Should fail, config exist (flow) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index b6499af5601..6e36778b75d 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -6,18 +6,17 @@ import aiohttp import pytest from homeassistant.components import ssdp -from homeassistant.generated import ssdp as gn_ssdp from tests.common import mock_coro async def test_scan_match_st(hass): """Test matching based on ST.""" - scanner = ssdp.Scanner(hass) + scanner = ssdp.Scanner(hass, {"mock-domain": [{"st": "mock-st"}]}) with patch( "netdisco.ssdp.scan", return_value=[Mock(st="mock-st", location=None)] - ), patch.dict(gn_ssdp.SSDP, {"mock-domain": [{"st": "mock-st"}]}), patch.object( + ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: await scanner.async_scan(None) @@ -42,12 +41,12 @@ async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key): """, ) - scanner = ssdp.Scanner(hass) + scanner = ssdp.Scanner(hass, {"mock-domain": [{key: "Paulus"}]}) with patch( "netdisco.ssdp.scan", return_value=[Mock(st="mock-st", location="http://1.1.1.1")], - ), patch.dict(gn_ssdp.SSDP, {"mock-domain": [{key: "Paulus"}]}), patch.object( + ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: await scanner.async_scan(None) @@ -69,13 +68,8 @@ async def test_scan_not_all_present(hass, aioclient_mock): """, ) - scanner = ssdp.Scanner(hass) - - with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1")], - ), patch.dict( - gn_ssdp.SSDP, + scanner = ssdp.Scanner( + hass, { "mock-domain": [ { @@ -84,6 +78,11 @@ async def test_scan_not_all_present(hass, aioclient_mock): } ] }, + ) + + with patch( + "netdisco.ssdp.scan", + return_value=[Mock(st="mock-st", location="http://1.1.1.1")], ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -105,13 +104,8 @@ async def test_scan_not_all_match(hass, aioclient_mock): """, ) - scanner = ssdp.Scanner(hass) - - with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1")], - ), patch.dict( - gn_ssdp.SSDP, + scanner = ssdp.Scanner( + hass, { "mock-domain": [ { @@ -120,6 +114,11 @@ async def test_scan_not_all_match(hass, aioclient_mock): } ] }, + ) + + with patch( + "netdisco.ssdp.scan", + return_value=[Mock(st="mock-st", location="http://1.1.1.1")], ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -132,7 +131,7 @@ async def test_scan_not_all_match(hass, aioclient_mock): async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): """Test failing to fetch description.""" aioclient_mock.get("http://1.1.1.1", exc=exc) - scanner = ssdp.Scanner(hass) + scanner = ssdp.Scanner(hass, {}) with patch( "netdisco.ssdp.scan", @@ -149,7 +148,7 @@ async def test_scan_description_parse_fail(hass, aioclient_mock): INVALIDXML """, ) - scanner = ssdp.Scanner(hass) + scanner = ssdp.Scanner(hass, {}) with patch( "netdisco.ssdp.scan", diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index 009701ca886..d19ca2261bb 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -1,5 +1,9 @@ """Test system log component.""" +import asyncio import logging +import queue + +import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components import system_log @@ -11,13 +15,34 @@ _LOGGER = logging.getLogger("test_logger") BASIC_CONFIG = {"system_log": {"max_entries": 2}} +@pytest.fixture +def simple_queue(): + """Fixture that get the queue.""" + simple_queue_fixed = queue.SimpleQueue() + with patch( + "homeassistant.components.system_log.queue.SimpleQueue", + return_value=simple_queue_fixed, + ): + yield simple_queue_fixed + + +async def _async_block_until_queue_empty(hass, sq): + # Unfortunately we are stuck with polling + await hass.async_block_till_done() + while not sq.empty(): + await asyncio.sleep(0.01) + await hass.async_block_till_done() + + async def get_error_log(hass, hass_client, expected_count): """Fetch all entries from system_log via the API.""" + client = await hass_client() resp = await client.get("/api/error/all") assert resp.status == 200 data = await resp.json() + assert len(data) == expected_count return data @@ -46,41 +71,49 @@ def get_frame(name): return (name, 5, None, None) -async def test_normal_logs(hass, hass_client): +async def test_normal_logs(hass, simple_queue, hass_client): """Test that debug and info are not logged.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) + _LOGGER.debug("debug") _LOGGER.info("info") + await _async_block_until_queue_empty(hass, simple_queue) # Assert done by get_error_log await get_error_log(hass, hass_client, 0) -async def test_exception(hass, hass_client): +async def test_exception(hass, simple_queue, hass_client): """Test that exceptions are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _generate_and_log_exception("exception message", "log message") + await _async_block_until_queue_empty(hass, simple_queue) + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, "exception message", "log message", "ERROR") -async def test_warning(hass, hass_client): +async def test_warning(hass, simple_queue, hass_client): """Test that warning are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.warning("warning message") + await _async_block_until_queue_empty(hass, simple_queue) + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, "", "warning message", "WARNING") -async def test_error(hass, hass_client): +async def test_error(hass, simple_queue, hass_client): """Test that errors are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message") + await _async_block_until_queue_empty(hass, simple_queue) + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, "", "error message", "ERROR") -async def test_config_not_fire_event(hass): +async def test_config_not_fire_event(hass, simple_queue): """Test that errors are not posted as events with default config.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) events = [] @@ -93,12 +126,12 @@ async def test_config_not_fire_event(hass): hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener) _LOGGER.error("error message") - await hass.async_block_till_done() + await _async_block_until_queue_empty(hass, simple_queue) assert len(events) == 0 -async def test_error_posted_as_event(hass): +async def test_error_posted_as_event(hass, simple_queue): """Test that error are posted as events.""" await async_setup_component( hass, system_log.DOMAIN, {"system_log": {"max_entries": 2, "fire_event": True}} @@ -113,26 +146,30 @@ async def test_error_posted_as_event(hass): hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener) _LOGGER.error("error message") - await hass.async_block_till_done() + await _async_block_until_queue_empty(hass, simple_queue) assert len(events) == 1 assert_log(events[0].data, "", "error message", "ERROR") -async def test_critical(hass, hass_client): +async def test_critical(hass, simple_queue, hass_client): """Test that critical are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.critical("critical message") + await _async_block_until_queue_empty(hass, simple_queue) + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, "", "critical message", "CRITICAL") -async def test_remove_older_logs(hass, hass_client): +async def test_remove_older_logs(hass, simple_queue, hass_client): """Test that older logs are rotated out.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message 1") _LOGGER.error("error message 2") _LOGGER.error("error message 3") + await _async_block_until_queue_empty(hass, simple_queue) + log = await get_error_log(hass, hass_client, 2) assert_log(log[0], "", "error message 3", "ERROR") assert_log(log[1], "", "error message 2", "ERROR") @@ -143,19 +180,23 @@ def log_msg(nr=2): _LOGGER.error("error message %s", nr) -async def test_dedup_logs(hass, hass_client): +async def test_dedup_logs(hass, simple_queue, hass_client): """Test that duplicate log entries are dedup.""" await async_setup_component(hass, system_log.DOMAIN, {}) _LOGGER.error("error message 1") log_msg() log_msg("2-2") _LOGGER.error("error message 3") + await _async_block_until_queue_empty(hass, simple_queue) + log = await get_error_log(hass, hass_client, 3) assert_log(log[0], "", "error message 3", "ERROR") assert log[1]["count"] == 2 assert_log(log[1], "", ["error message 2", "error message 2-2"], "ERROR") log_msg() + await _async_block_until_queue_empty(hass, simple_queue) + log = await get_error_log(hass, hass_client, 3) assert_log(log[0], "", ["error message 2", "error message 2-2"], "ERROR") assert log[0]["timestamp"] > log[0]["first_occurred"] @@ -164,6 +205,8 @@ async def test_dedup_logs(hass, hass_client): log_msg("2-4") log_msg("2-5") log_msg("2-6") + await _async_block_until_queue_empty(hass, simple_queue) + log = await get_error_log(hass, hass_client, 3) assert_log( log[0], @@ -179,15 +222,16 @@ async def test_dedup_logs(hass, hass_client): ) -async def test_clear_logs(hass, hass_client): +async def test_clear_logs(hass, simple_queue, hass_client): """Test that the log can be cleared via a service call.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message") + await _async_block_until_queue_empty(hass, simple_queue) hass.async_add_job( hass.services.async_call(system_log.DOMAIN, system_log.SERVICE_CLEAR, {}) ) - await hass.async_block_till_done() + await _async_block_until_queue_empty(hass, simple_queue) # Assert done by get_error_log await get_error_log(hass, hass_client, 0) @@ -239,16 +283,17 @@ async def test_write_choose_level(hass): assert logger.method_calls[0] == ("debug", ("test_message",)) -async def test_unknown_path(hass, hass_client): +async def test_unknown_path(hass, simple_queue, hass_client): """Test error logged from unknown path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.findCaller = MagicMock(return_value=("unknown_path", 0, None, None)) _LOGGER.error("error message") + await _async_block_until_queue_empty(hass, simple_queue) log = (await get_error_log(hass, hass_client, 1))[0] assert log["source"] == ["unknown_path", 0] -def log_error_from_test_path(path): +async def async_log_error_from_test_path(hass, path, sq): """Log error while mocking the path.""" call_path = "internal_path.py" with patch.object( @@ -266,24 +311,29 @@ def log_error_from_test_path(path): ), ): _LOGGER.error("error message") + await _async_block_until_queue_empty(hass, sq) -async def test_homeassistant_path(hass, hass_client): +async def test_homeassistant_path(hass, simple_queue, hass_client): """Test error logged from Home Assistant path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch( "homeassistant.components.system_log.HOMEASSISTANT_PATH", new=["venv_path/homeassistant"], ): - log_error_from_test_path("venv_path/homeassistant/component/component.py") + await async_log_error_from_test_path( + hass, "venv_path/homeassistant/component/component.py", simple_queue + ) log = (await get_error_log(hass, hass_client, 1))[0] assert log["source"] == ["component/component.py", 5] -async def test_config_path(hass, hass_client): +async def test_config_path(hass, simple_queue, hass_client): """Test error logged from config path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.object(hass.config, "config_dir", new="config"): - log_error_from_test_path("config/custom_component/test.py") + await async_log_error_from_test_path( + hass, "config/custom_component/test.py", simple_queue + ) log = (await get_error_log(hass, hass_client, 1))[0] assert log["source"] == ["custom_component/test.py", 5] diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 1126f6b60a1..e27359fd56e 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -586,3 +586,32 @@ async def test_disarm_action(hass): await hass.async_block_till_done() assert len(service_calls) == 1 + + +async def test_unique_id(hass): + """Test unique_id option only creates one alarm control panel per id.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_alarm_control_panel_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + }, + "test_template_alarm_control_panel_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + }, + }, + }, + }, + ) + + 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/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 482a72082cd..aeee08dc757 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -253,6 +253,7 @@ class TestBinarySensorTemplate(unittest.TestCase): None, None, None, + None, ).result() assert not vs.should_poll assert "motion" == vs.device_class @@ -315,6 +316,7 @@ class TestBinarySensorTemplate(unittest.TestCase): None, None, None, + None, ).result() mock_render.side_effect = TemplateError("foo") run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() @@ -640,3 +642,32 @@ async def test_no_update_template_match_all(hass, caplog): assert hass.states.get("binary_sensor.all_icon").state == "off" assert hass.states.get("binary_sensor.all_entity_picture").state == "off" assert hass.states.get("binary_sensor.all_attribute").state == "off" + + +async def test_unique_id(hass): + """Test unique_id option only creates one binary sensor per id.""" + await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_template_cover_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + }, + "test_template_cover_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + }, + }, + }, + }, + ) + + 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/template/test_cover.py b/tests/components/template/test_cover.py index 56db2445f6b..d51d71648bf 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -1034,3 +1034,48 @@ async def test_invalid_device_class(hass, calls): state = hass.states.get("cover.test_template_cover") assert not state + + +async def test_unique_id(hass): + """Test unique_id option only creates one cover per id.""" + await setup.async_setup_component( + hass, + "cover", + { + "cover": { + "platform": "template", + "covers": { + "test_template_cover_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + }, + "test_template_cover_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + }, + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 2e44ec6f0ca..a56b55fa123 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -222,6 +222,64 @@ async def test_templates_with_entities(hass, calls): _verify(hass, STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) +async def test_template_with_unavailable_entities(hass, calls): + """Test unavailability with value_template.""" + + with assert_setup_component(1, "fan"): + assert await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'unavailable' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + assert hass.states.get(_TEST_FAN).state == STATE_OFF + + +async def test_template_with_unavailable_parameters(hass, calls): + """Test unavailability of speed, direction and oscillating parameters.""" + + with assert_setup_component(1, "fan"): + assert await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "speed_template": "{{ 'unavailable' }}", + "oscillating_template": "{{ 'unavailable' }}", + "direction_template": "{{ 'unavailable' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, None, None) + + async def test_availability_template_with_entities(hass, calls): """Test availability tempalates with values from other entities.""" @@ -700,3 +758,48 @@ async def _register_components(hass, speed_list=None): 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 fan per id.""" + await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_template_fan_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "turn_on": { + "service": "fan.turn_on", + "entity_id": "fan.test_state", + }, + "turn_off": { + "service": "fan.turn_off", + "entity_id": "fan.test_state", + }, + }, + "test_template_fan_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "turn_on": { + "service": "fan.turn_on", + "entity_id": "fan.test_state", + }, + "turn_off": { + "service": "fan.turn_off", + "entity_id": "fan.test_state", + }, + }, + }, + }, + }, + ) + + 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/template/test_light.py b/tests/components/template/test_light.py index 8e27a6ba15d..5353aaa7d17 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -1164,3 +1164,46 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE assert ("UndefinedError: 'x' is undefined") in caplog.text + + +async def test_unique_id(hass): + """Test unique_id option only creates one light per id.""" + await setup.async_setup_component( + hass, + "light", + { + "light": { + "platform": "template", + "lights": { + "test_template_light_01": { + "unique_id": "not-so-unique-anymore", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + }, + "test_template_light_02": { + "unique_id": "not-so-unique-anymore", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + }, + }, + }, + }, + ) + + 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/template/test_lock.py b/tests/components/template/test_lock.py index 1389040c4bb..4c15babdfe2 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -406,3 +406,48 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap assert hass.states.get("lock.template_lock").state != STATE_UNAVAILABLE assert ("UndefinedError: 'x' is undefined") in caplog.text + + +async def test_unique_id(hass): + """Test unique_id option only creates one lock per id.""" + await setup.async_setup_component( + hass, + "lock", + { + "lock": { + "platform": "template", + "name": "test_template_lock_01", + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "lock": {"service": "switch.turn_on", "entity_id": "switch.test_state"}, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + }, + ) + + await setup.async_setup_component( + hass, + "lock", + { + "lock": { + "platform": "template", + "name": "test_template_lock_02", + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "lock": {"service": "switch.turn_on", "entity_id": "switch.test_state"}, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index d61b1be4a7f..3899a7b3afe 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1,11 +1,16 @@ """The test for the Template sensor platform.""" +from asyncio import Event +from unittest.mock import patch + +from homeassistant.bootstrap import async_from_config_dict from homeassistant.const import ( + EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.setup import async_setup_component, setup_component +from homeassistant.setup import ATTR_COMPONENT, async_setup_component, setup_component from tests.common import assert_setup_component, get_test_home_assistant @@ -438,6 +443,45 @@ class TestTemplateSensor: ) +async def test_creating_sensor_loads_group(hass): + """Test setting up template sensor loads group component first.""" + order = [] + after_dep_event = Event() + + async def async_setup_group(hass, config): + # Make sure group takes longer to load, so that it won't + # be loaded first by chance + await after_dep_event.wait() + + order.append("group") + return True + + async def async_setup_template( + hass, config, async_add_entities, discovery_info=None + ): + order.append("sensor.template") + return True + + async def set_after_dep_event(event): + if event.data[ATTR_COMPONENT] == "sensor": + after_dep_event.set() + + hass.bus.async_listen(EVENT_COMPONENT_LOADED, set_after_dep_event) + + with patch( + "homeassistant.components.group.async_setup", new=async_setup_group, + ), patch( + "homeassistant.components.template.sensor.async_setup_platform", + new=async_setup_template, + ): + await async_from_config_dict( + {"sensor": {"platform": "template", "sensors": {}}, "group": {}}, hass + ) + await hass.async_block_till_done() + + assert order == ["group", "sensor.template"] + + async def test_available_template_with_entities(hass): """Test availability tempalates with values from other entities.""" hass.states.async_set("sensor.availability_sensor", STATE_OFF) @@ -639,3 +683,32 @@ async def test_no_template_match_all(hass, caplog): assert hass.states.get("sensor.invalid_entity_picture").state == "hello" assert hass.states.get("sensor.invalid_friendly_name").state == "hello" assert hass.states.get("sensor.invalid_attribute").state == "hello" + + +async def test_unique_id(hass): + """Test unique_id option only creates one sensor per id.""" + await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + }, + "test_template_sensor_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + }, + }, + } + }, + ) + + 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/template/test_switch.py b/tests/components/template/test_switch.py index a252bea9758..191e26a4266 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -650,3 +650,48 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap assert hass.states.get("switch.test_template_switch").state != STATE_UNAVAILABLE assert ("UndefinedError: 'x' is undefined") in caplog.text + + +async def test_unique_id(hass): + """Test unique_id option only creates one switch per id.""" + await setup.async_setup_component( + hass, + "switch", + { + "switch": { + "platform": "template", + "switches": { + "test_template_switch_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "turn_on": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "turn_off": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + "test_template_switch_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "turn_on": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "turn_off": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 19cd9f0a8ee..fd77e5455c6 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -612,3 +612,34 @@ 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/updater/test_init.py b/tests/components/updater/test_init.py index 203a4df8355..89ebf9e1bbb 100644 --- a/tests/components/updater/test_init.py +++ b/tests/components/updater/test_init.py @@ -154,12 +154,7 @@ async def test_new_version_shows_entity_after_hour_hassio( """Test if binary sensor gets updated if new version is available / Hass.io.""" mock_get_uuid.return_value = MOCK_HUUID mock_component(hass, "hassio") - hass.data["hassio_info"] = {"hassos": None, "homeassistant": "999.0"} - hass.data["hassio_host"] = { - "supervisor": "222", - "chassis": "vm", - "operating_system": "HassOS 4.6", - } + hass.data["hassio_core_info"] = {"version_latest": "999.0"} assert await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}}) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 09145fc4e4e..c1613c53a20 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -260,49 +260,49 @@ async def _test_self_reset(hass, config, start_time, expect_reset=True): assert state.state == "5" -async def test_self_reset_hourly(hass): +async def test_self_reset_hourly(hass, legacy_patchable_time): """Test hourly reset of meter.""" await _test_self_reset( hass, gen_config("hourly"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_reset_daily(hass): +async def test_self_reset_daily(hass, legacy_patchable_time): """Test daily reset of meter.""" await _test_self_reset( hass, gen_config("daily"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_reset_weekly(hass): +async def test_self_reset_weekly(hass, legacy_patchable_time): """Test weekly reset of meter.""" await _test_self_reset( hass, gen_config("weekly"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_reset_monthly(hass): +async def test_self_reset_monthly(hass, legacy_patchable_time): """Test monthly reset of meter.""" await _test_self_reset( hass, gen_config("monthly"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_reset_quarterly(hass): +async def test_self_reset_quarterly(hass, legacy_patchable_time): """Test quarterly reset of meter.""" await _test_self_reset( hass, gen_config("quarterly"), "2017-03-31T23:59:00.000000+00:00" ) -async def test_self_reset_yearly(hass): +async def test_self_reset_yearly(hass, legacy_patchable_time): """Test yearly reset of meter.""" await _test_self_reset( hass, gen_config("yearly"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_no_reset_yearly(hass): +async def test_self_no_reset_yearly(hass, legacy_patchable_time): """Test yearly reset of meter does not occur after 1st January.""" await _test_self_reset( hass, @@ -312,7 +312,7 @@ async def test_self_no_reset_yearly(hass): ) -async def test_reset_yearly_offset(hass): +async def test_reset_yearly_offset(hass, legacy_patchable_time): """Test yearly reset of meter.""" await _test_self_reset( hass, @@ -321,7 +321,7 @@ async def test_reset_yearly_offset(hass): ) -async def test_no_reset_yearly_offset(hass): +async def test_no_reset_yearly_offset(hass, legacy_patchable_time): """Test yearly reset of meter.""" await _test_self_reset( hass, diff --git a/tests/components/volumio/__init__.py b/tests/components/volumio/__init__.py new file mode 100644 index 00000000000..7d8a443aaf8 --- /dev/null +++ b/tests/components/volumio/__init__.py @@ -0,0 +1 @@ +"""Tests for the Volumio integration.""" diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py new file mode 100644 index 00000000000..a7ed4773142 --- /dev/null +++ b/tests/components/volumio/test_config_flow.py @@ -0,0 +1,252 @@ +"""Test the Volumio config flow.""" +from homeassistant import config_entries +from homeassistant.components.volumio.config_flow import CannotConnectError +from homeassistant.components.volumio.const import DOMAIN + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +TEST_SYSTEM_INFO = {"id": "1111-1111-1111-1111", "name": "TestVolumio"} + + +TEST_CONNECTION = { + "host": "1.1.1.1", + "port": 3000, +} + + +TEST_DISCOVERY = { + "host": "1.1.1.1", + "port": 3000, + "properties": {"volumioName": "discovered", "UUID": "2222-2222-2222-2222"}, +} + +TEST_DISCOVERY_RESULT = { + "host": TEST_DISCOVERY["host"], + "port": TEST_DISCOVERY["port"], + "id": TEST_DISCOVERY["properties"]["UUID"], + "name": TEST_DISCOVERY["properties"]["volumioName"], +} + + +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"] == {} + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "TestVolumio" + assert result2["data"] == {**TEST_SYSTEM_INFO, **TEST_CONNECTION} + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_updates_unique_id(hass): + """Test a duplicate id aborts and updates existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SYSTEM_INFO["id"], + data={ + "host": "dummy", + "port": 11, + "name": "dummy", + "id": TEST_SYSTEM_INFO["id"], + }, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch("homeassistant.components.volumio.async_setup", return_value=True), patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + assert entry.data == {**TEST_SYSTEM_INFO, **TEST_CONNECTION} + + +async def test_empty_system_info(hass): + """Test old volumio versions with empty system info.""" + 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.volumio.config_flow.Volumio.get_system_info", + return_value={}, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_CONNECTION["host"] + assert result2["data"] == { + "host": TEST_CONNECTION["host"], + "port": TEST_CONNECTION["port"], + "name": TEST_CONNECTION["host"], + "id": None, + } + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + side_effect=CannotConnectError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_exception(hass): + """Test we handle generic error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_discovery(hass): + """Test discovery flow works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_DISCOVERY_RESULT["name"] + assert result2["data"] == TEST_DISCOVERY_RESULT + + assert result2["result"] + assert result2["result"].unique_id == TEST_DISCOVERY_RESULT["id"] + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovery_cannot_connect(hass): + """Test discovery aborts if cannot connect.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + side_effect=CannotConnectError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "cannot_connect" + + +async def test_discovery_duplicate_data(hass): + """Test discovery aborts if same mDNS packet arrives.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + assert result["type"] == "form" + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + assert result["type"] == "abort" + assert result["reason"] == "already_in_progress" + + +async def test_discovery_updates_unique_id(hass): + """Test a duplicate discovery id aborts and updates existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_DISCOVERY_RESULT["id"], + data={ + "host": "dummy", + "port": 11, + "name": "dummy", + "id": TEST_DISCOVERY_RESULT["id"], + }, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + assert entry.data == TEST_DISCOVERY_RESULT diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index b646c667472..8f3347c8867 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.withings.common import ( async_get_entity_id, ) from homeassistant.components.withings.const import Measurement -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -37,7 +37,7 @@ async def test_binary_sensor( assert entity_id1 assert entity_registry.async_is_registered(entity_id0) - assert hass.states.get(entity_id0).state == STATE_OFF + assert hass.states.get(entity_id0).state == STATE_UNAVAILABLE resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_IN) assert resp.message_code == 0 @@ -50,7 +50,7 @@ async def test_binary_sensor( assert hass.states.get(entity_id0).state == STATE_OFF # person 1 - assert hass.states.get(entity_id1).state == STATE_OFF + assert hass.states.get(entity_id1).state == STATE_UNAVAILABLE resp = await component_factory.call_webhook(person1.user_id, NotifyAppli.BED_IN) assert resp.message_code == 0 diff --git a/tests/components/wolflink/__init__.py b/tests/components/wolflink/__init__.py new file mode 100644 index 00000000000..dea7c5195ad --- /dev/null +++ b/tests/components/wolflink/__init__.py @@ -0,0 +1 @@ +"""Tests for the Wolf SmartSet Service integration.""" diff --git a/tests/components/wolflink/test_config_flow.py b/tests/components/wolflink/test_config_flow.py new file mode 100644 index 00000000000..f2074f482eb --- /dev/null +++ b/tests/components/wolflink/test_config_flow.py @@ -0,0 +1,139 @@ +"""Test the Wolf SmartSet Service config flow.""" +from httpcore import ConnectError +from wolf_smartset.models import Device +from wolf_smartset.token_auth import InvalidAuth + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.wolflink.const import ( + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +CONFIG = { + DEVICE_NAME: "test-device", + DEVICE_ID: 1234, + DEVICE_GATEWAY: 5678, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + +INPUT_CONFIG = { + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], +} + +DEVICE = Device(CONFIG[DEVICE_ID], CONFIG[DEVICE_GATEWAY], CONFIG[DEVICE_NAME]) + + +async def test_show_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_device_step_form(hass): + """Test we get the second step of config.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + return_value=[DEVICE], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "device" + + +async def test_create_entry(hass): + """Test entity creation from device step.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + return_value=[DEVICE], + ), patch("homeassistant.components.wolflink.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + result_create_entry = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device_name": CONFIG[DEVICE_NAME]}, + ) + + assert result_create_entry["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result_create_entry["title"] == CONFIG[DEVICE_NAME] + assert result_create_entry["data"] == CONFIG + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + side_effect=ConnectError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass): + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown"} + + +async def test_already_configured_error(hass): + """Test already configured while creating entry.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + return_value=[DEVICE], + ), patch("homeassistant.components.wolflink.async_setup_entry", return_value=True): + + MockConfigEntry( + domain=DOMAIN, unique_id=CONFIG[DEVICE_ID], data=CONFIG + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + result_create_entry = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device_name": CONFIG[DEVICE_NAME]}, + ) + + assert result_create_entry["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result_create_entry["reason"] == "already_configured" diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index b7762317fdf..06fda84c934 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -34,13 +34,22 @@ def xiaomi_aqara_fixture(): with patch( "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", return_value=mock_gateway_discovery, + ), patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGateway", + return_value=mock_gateway_discovery.gateways[TEST_HOST], ), patch( "homeassistant.components.xiaomi_aqara.async_setup_entry", return_value=True ): yield -def get_mock_discovery(host_list, invalid_interface=False, invalid_key=False): +def get_mock_discovery( + host_list, + invalid_interface=False, + invalid_key=False, + invalid_host=False, + invalid_mac=False, +): """Return a mock gateway info instance.""" gateway_discovery = Mock() @@ -52,6 +61,8 @@ def get_mock_discovery(host_list, invalid_interface=False, invalid_key=False): gateway.port = TEST_PORT gateway.sid = TEST_SID gateway.proto = TEST_PROTOCOL + gateway.connection_error = invalid_host + gateway.mac_error = invalid_mac if invalid_key: gateway.write_to_hub = Mock(return_value=False) @@ -185,6 +196,52 @@ async def test_config_flow_user_no_key_success(hass): } +async def test_config_flow_user_host_mac_success(hass): + """Test a successful config flow initialized by the user with a host and mac specified.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_gateway_discovery = get_mock_discovery([]) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", + return_value=mock_gateway_discovery, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "settings" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_NAME: TEST_NAME}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_MAC: TEST_MAC, + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + const.CONF_PROTOCOL: TEST_PROTOCOL, + const.CONF_KEY: None, + const.CONF_SID: TEST_SID, + } + + async def test_config_flow_user_discovery_error(hass): """Test a failed config flow initialized by the user with no gateways discoverd.""" result = await hass.config_entries.flow.async_init( @@ -235,6 +292,66 @@ async def test_config_flow_user_invalid_interface(hass): assert result["errors"] == {const.CONF_INTERFACE: "invalid_interface"} +async def test_config_flow_user_invalid_host(hass): + """Test a failed config flow initialized by the user with an invalid host.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_gateway_discovery = get_mock_discovery([TEST_HOST], invalid_host=True) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGateway", + return_value=mock_gateway_discovery.gateways[TEST_HOST], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + CONF_HOST: "0.0.0.0", + CONF_MAC: TEST_MAC, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"host": "invalid_host"} + + +async def test_config_flow_user_invalid_mac(hass): + """Test a failed config flow initialized by the user with an invalid mac.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_gateway_discovery = get_mock_discovery([TEST_HOST], invalid_mac=True) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGateway", + return_value=mock_gateway_discovery.gateways[TEST_HOST], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + CONF_HOST: TEST_HOST, + CONF_MAC: "in:va:li:d0:0m:ac", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"mac": "invalid_mac"} + + async def test_config_flow_user_invalid_key(hass): """Test a failed config flow initialized by the user with an invalid key.""" result = await hass.config_entries.flow.async_init( @@ -335,34 +452,3 @@ async def test_zeroconf_unknown_device(hass): assert result["type"] == "abort" assert result["reason"] == "not_xiaomi_aqara" - - -async def test_zeroconf_not_found_error(hass): - """Test a failed zeroconf discovery because the correct gateway could not be found.""" - result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - zeroconf.ATTR_HOST: TEST_HOST, - ZEROCONF_NAME: TEST_ZEROCONF_NAME, - ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, - }, - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - mock_gateway_discovery = get_mock_discovery([TEST_HOST_2]) - - with patch( - "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", - return_value=mock_gateway_discovery, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "not_found_error"} diff --git a/tests/components/yandex_transport/test_yandex_transport_sensor.py b/tests/components/yandex_transport/test_yandex_transport_sensor.py index e5b6f31990b..069d171a371 100644 --- a/tests/components/yandex_transport/test_yandex_transport_sensor.py +++ b/tests/components/yandex_transport/test_yandex_transport_sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_NAME from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.async_mock import patch +from tests.async_mock import AsyncMock, patch from tests.common import assert_setup_component, load_fixture REPLY = json.loads(load_fixture("yandex_transport_reply.json")) @@ -17,10 +17,10 @@ REPLY = json.loads(load_fixture("yandex_transport_reply.json")) @pytest.fixture def mock_requester(): - """Create a mock ya_ma module and YandexMapsRequester.""" - with patch("ya_ma.YandexMapsRequester") as requester: + """Create a mock for YandexMapsRequester.""" + with patch("aioymaps.YandexMapsRequester") as requester: instance = requester.return_value - instance.get_stop_info.return_value = REPLY + instance.get_stop_info = AsyncMock(return_value=REPLY) yield instance diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py new file mode 100644 index 00000000000..7f1f7d7d236 --- /dev/null +++ b/tests/components/yeelight/__init__.py @@ -0,0 +1,87 @@ +"""Tests for the Yeelight integration.""" +from yeelight import BulbType +from yeelight.main import _MODEL_SPECS + +from homeassistant.components.yeelight import ( + CONF_MODE_MUSIC, + CONF_NIGHTLIGHT_SWITCH_TYPE, + CONF_SAVE_ON_CHANGE, + DOMAIN, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, +) +from homeassistant.const import CONF_DEVICES, CONF_NAME + +from tests.async_mock import MagicMock + +IP_ADDRESS = "192.168.1.239" +MODEL = "color" +ID = "0x000000000015243f" +FW_VER = "18" + +CAPABILITIES = { + "id": ID, + "model": MODEL, + "fw_ver": FW_VER, + "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 = f"yeelight_{MODEL}_{ID}" + +MODULE = "homeassistant.components.yeelight" +MODULE_CONFIG_FLOW = f"{MODULE}.config_flow" + +PROPERTIES = { + "power": "on", + "main_power": "on", + "bright": "50", + "ct": "4000", + "rgb": "16711680", + "hue": "100", + "sat": "35", + "color_mode": "1", + "flowing": "0", + "bg_power": "on", + "bg_lmode": "1", + "bg_flowing": "0", + "bg_ct": "5000", + "bg_bright": "80", + "bg_rgb": "16711680", + "nl_br": "23", + "active_mode": "0", + "current_brightness": "30", +} + +ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" +ENTITY_LIGHT = f"light.{NAME}" +ENTITY_NIGHTLIGHT = f"light.{NAME}_nightlight" + +YAML_CONFIGURATION = { + DOMAIN: { + CONF_DEVICES: { + IP_ADDRESS: { + CONF_NAME: NAME, + CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT, + CONF_MODE_MUSIC: True, + CONF_SAVE_ON_CHANGE: True, + } + } + } +} + + +def _mocked_bulb(cannot_connect=False): + bulb = MagicMock() + type(bulb).get_capabilities = MagicMock( + return_value=None if cannot_connect else CAPABILITIES + ) + 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.music_mode = False + + return bulb diff --git a/tests/components/yeelight/test_binary_sensor.py b/tests/components/yeelight/test_binary_sensor.py new file mode 100644 index 00000000000..bf20a7ec5b0 --- /dev/null +++ b/tests/components/yeelight/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Test the Yeelight binary sensor.""" +from homeassistant.components.yeelight import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_component +from homeassistant.setup import async_setup_component + +from . import ENTITY_BINARY_SENSOR, MODULE, PROPERTIES, YAML_CONFIGURATION, _mocked_bulb + +from tests.async_mock import patch + + +async def test_nightlight(hass: HomeAssistant): + """Test nightlight sensor.""" + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) + await hass.async_block_till_done() + + # active_mode + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "off" + + # nl_br + properties = {**PROPERTIES} + properties.pop("active_mode") + mocked_bulb.last_properties = properties + await entity_component.async_update_entity(hass, ENTITY_BINARY_SENSOR) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "on" + + # default + properties.pop("nl_br") + await entity_component.async_update_entity(hass, ENTITY_BINARY_SENSOR) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "off" diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py new file mode 100644 index 00000000000..c44c343e51b --- /dev/null +++ b/tests/components/yeelight/test_light.py @@ -0,0 +1,546 @@ +"""Test the Yeelight light.""" +import logging + +from yeelight import ( + BulbException, + BulbType, + HSVTransition, + LightType, + PowerMode, + RGBTransition, + SceneClass, + SleepTransition, + TemperatureTransition, + transitions, +) +from yeelight.flow import Flow +from yeelight.main import _MODEL_SPECS + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_KELVIN, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + FLASH_LONG, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.yeelight import ( + ATTR_COUNT, + ATTR_TRANSITIONS, + CONF_CUSTOM_EFFECTS, + CONF_FLOW_PARAMS, + CONF_NIGHTLIGHT_SWITCH_TYPE, + DEFAULT_TRANSITION, + DOMAIN, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, + YEELIGHT_HSV_TRANSACTION, + YEELIGHT_RGB_TRANSITION, + YEELIGHT_SLEEP_TRANSACTION, + YEELIGHT_TEMPERATURE_TRANSACTION, +) +from homeassistant.components.yeelight.light import ( + ATTR_MINUTES, + ATTR_MODE, + EFFECT_DISCO, + EFFECT_FACEBOOK, + EFFECT_FAST_RANDOM_LOOP, + EFFECT_STOP, + EFFECT_TWITTER, + EFFECT_WHATSAPP, + SERVICE_SET_AUTO_DELAY_OFF_SCENE, + SERVICE_SET_COLOR_FLOW_SCENE, + SERVICE_SET_COLOR_SCENE, + SERVICE_SET_COLOR_TEMP_SCENE, + SERVICE_SET_HSV_SCENE, + SERVICE_SET_MODE, + SERVICE_START_FLOW, + SUPPORT_YEELIGHT, + SUPPORT_YEELIGHT_RGB, + SUPPORT_YEELIGHT_WHITE_TEMP, + YEELIGHT_COLOR_EFFECT_LIST, + YEELIGHT_MONO_EFFECT_LIST, + YEELIGHT_TEMP_ONLY_EFFECT_LIST, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.color import ( + color_hs_to_RGB, + color_hs_to_xy, + color_RGB_to_hs, + color_RGB_to_xy, + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) + +from . import ( + CAPABILITIES, + ENTITY_LIGHT, + ENTITY_NIGHTLIGHT, + MODULE, + NAME, + PROPERTIES, + YAML_CONFIGURATION, + _mocked_bulb, +) + +from tests.async_mock import MagicMock, patch + + +async def test_services(hass: HomeAssistant, caplog): + """Test Yeelight services.""" + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) + await hass.async_block_till_done() + + async def _async_test_service(service, data, method, payload=None, domain=DOMAIN): + 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) + await hass.services.async_call(domain, service, data, blocking=True) + if payload is None: + mocked_method.assert_called_once() + elif type(payload) == list: + mocked_method.assert_called_once_with(*payload) + else: + mocked_method.assert_called_once_with(**payload) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) == err_count + ) + + # failure + mocked_method = MagicMock(side_effect=BulbException) + setattr(type(mocked_bulb), method, mocked_method) + await hass.services.async_call(domain, service, data, blocking=True) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) + == err_count + 1 + ) + + # turn_on + brightness = 100 + color_temp = 200 + transition = 1 + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS: brightness, + ATTR_COLOR_TEMP: color_temp, + ATTR_FLASH: FLASH_LONG, + ATTR_EFFECT: EFFECT_STOP, + ATTR_TRANSITION: transition, + }, + blocking=True, + ) + mocked_bulb.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.start_music.assert_called_once() + mocked_bulb.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( + color_temperature_mired_to_kelvin(color_temp), + duration=transition * 1000, + light_type=LightType.Main, + ) + mocked_bulb.start_flow.assert_called_once() # flash + mocked_bulb.stop_flow.assert_called_once_with(light_type=LightType.Main) + + # turn_on nightlight + await _async_test_service( + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_NIGHTLIGHT}, + "turn_on", + payload={ + "duration": DEFAULT_TRANSITION, + "light_type": LightType.Main, + "power_mode": PowerMode.MOONLIGHT, + }, + domain="light", + ) + + # turn_off + await _async_test_service( + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITION: transition}, + "turn_off", + domain="light", + payload={"duration": transition * 1000, "light_type": LightType.Main}, + ) + + # set_mode + mode = "rgb" + await _async_test_service( + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MODE: "rgb"}, + "set_power_mode", + [PowerMode[mode.upper()]], + ) + + # start_flow + await _async_test_service( + SERVICE_START_FLOW, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], + }, + "start_flow", + ) + + # set_color_scene + await _async_test_service( + SERVICE_SET_COLOR_SCENE, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_RGB_COLOR: [10, 20, 30], + ATTR_BRIGHTNESS: 50, + }, + "set_scene", + [SceneClass.COLOR, 10, 20, 30, 50], + ) + + # set_hsv_scene + await _async_test_service( + SERVICE_SET_HSV_SCENE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_HS_COLOR: [180, 50], ATTR_BRIGHTNESS: 50}, + "set_scene", + [SceneClass.HSV, 180, 50, 50], + ) + + # set_color_temp_scene + await _async_test_service( + SERVICE_SET_COLOR_TEMP_SCENE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_KELVIN: 4000, ATTR_BRIGHTNESS: 50}, + "set_scene", + [SceneClass.CT, 4000, 50], + ) + + # set_color_flow_scene + await _async_test_service( + SERVICE_SET_COLOR_FLOW_SCENE, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], + }, + "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", + [SceneClass.AUTO_DELAY_OFF, 50, 1], + ) + + # test _cmd wrapper error handler + 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) + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 50}, + blocking=True, + ) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) == err_count + 1 + ) + + +async def test_device_types(hass: HomeAssistant): + """Test different device types.""" + properties = {**PROPERTIES} + properties.pop("active_mode") + properties["color_mode"] = "3" + + def _create_mocked_bulb(bulb_type, model, unique_id): + capabilities = {**CAPABILITIES} + capabilities["id"] = f"yeelight.{unique_id}" + mocked_bulb = _mocked_bulb() + mocked_bulb.bulb_type = bulb_type + mocked_bulb.last_properties = properties + mocked_bulb.capabilities = capabilities + model_specs = _MODEL_SPECS.get(model) + type(mocked_bulb).get_model_specs = MagicMock(return_value=model_specs) + return mocked_bulb + + types = { + "default": (None, "mono"), + "white": (BulbType.White, "mono"), + "color": (BulbType.Color, "color"), + "white_temp": (BulbType.WhiteTemp, "ceiling1"), + "white_temp_mood": (BulbType.WhiteTempMood, "ceiling4"), + "ambient": (BulbType.WhiteTempMood, "ceiling4"), + } + + devices = {} + mocked_bulbs = [] + unique_id = 0 + for name, (bulb_type, model) in types.items(): + devices[f"{name}.yeelight"] = {CONF_NAME: name} + devices[f"{name}_nightlight.yeelight"] = { + CONF_NAME: f"{name}_nightlight", + CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT, + } + mocked_bulbs.append(_create_mocked_bulb(bulb_type, model, unique_id)) + mocked_bulbs.append(_create_mocked_bulb(bulb_type, model, unique_id + 1)) + unique_id += 2 + + with patch(f"{MODULE}.Bulb", side_effect=mocked_bulbs): + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DEVICES: devices}}) + await hass.async_block_till_done() + + async def _async_test( + name, + bulb_type, + model, + target_properties, + nightlight_properties=None, + entity_name=None, + entity_id=None, + ): + if entity_id is None: + entity_id = f"light.{name}" + state = hass.states.get(entity_id) + assert state.state == "on" + target_properties["friendly_name"] = entity_name or name + target_properties["flowing"] = False + target_properties["night_light"] = True + assert dict(state.attributes) == target_properties + + # nightlight + if nightlight_properties is None: + return + name += "_nightlight" + entity_id = f"light.{name}" + assert hass.states.get(entity_id).state == "off" + state = hass.states.get(f"{entity_id}_nightlight") + assert state.state == "on" + nightlight_properties["friendly_name"] = f"{name} nightlight" + nightlight_properties["icon"] = "mdi:weather-night" + nightlight_properties["flowing"] = False + nightlight_properties["night_light"] = True + assert dict(state.attributes) == nightlight_properties + + bright = round(255 * int(PROPERTIES["bright"]) / 100) + current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100) + ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) + hue = int(PROPERTIES["hue"]) + sat = int(PROPERTIES["sat"]) + hs_color = (round(hue / 360 * 65536, 3), round(sat / 100 * 255, 3)) + rgb_color = color_hs_to_RGB(*hs_color) + xy_color = color_hs_to_xy(*hs_color) + bg_bright = round(255 * int(PROPERTIES["bg_bright"]) / 100) + bg_ct = color_temperature_kelvin_to_mired(int(PROPERTIES["bg_ct"])) + bg_rgb = int(PROPERTIES["bg_rgb"]) + bg_rgb_color = ((bg_rgb >> 16) & 0xFF, (bg_rgb >> 8) & 0xFF, bg_rgb & 0xFF) + bg_hs_color = color_RGB_to_hs(*bg_rgb_color) + bg_xy_color = color_RGB_to_xy(*bg_rgb_color) + nl_br = round(255 * int(PROPERTIES["nl_br"]) / 100) + + # Default + await _async_test( + "default", + None, + "mono", + { + "effect_list": YEELIGHT_MONO_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": bright, + }, + ) + + # White + await _async_test( + "white", + BulbType.White, + "mono", + { + "effect_list": YEELIGHT_MONO_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": bright, + }, + ) + + # Color + model_specs = _MODEL_SPECS["color"] + await _async_test( + "color", + BulbType.Color, + "color", + { + "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_RGB, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + "hs_color": hs_color, + "rgb_color": rgb_color, + "xy_color": xy_color, + }, + {"supported_features": 0}, + ) + + # WhiteTemp + model_specs = _MODEL_SPECS["ceiling1"] + await _async_test( + "white_temp", + BulbType.WhiteTemp, + "ceiling1", + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_WHITE_TEMP, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + }, + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": nl_br, + }, + ) + + # WhiteTempMood + model_specs = _MODEL_SPECS["ceiling4"] + await _async_test( + "white_temp_mood", + BulbType.WhiteTempMood, + "ceiling4", + { + "friendly_name": NAME, + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "flowing": False, + "night_light": True, + "supported_features": SUPPORT_YEELIGHT_WHITE_TEMP, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + }, + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": nl_br, + }, + ) + await _async_test( + "ambient", + BulbType.WhiteTempMood, + "ceiling4", + { + "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_RGB, + "min_mireds": color_temperature_kelvin_to_mired(6500), + "max_mireds": color_temperature_kelvin_to_mired(1700), + "brightness": bg_bright, + "color_temp": bg_ct, + "hs_color": bg_hs_color, + "rgb_color": bg_rgb_color, + "xy_color": bg_xy_color, + }, + entity_name="ambient ambilight", + entity_id="light.ambient_ambilight", + ) + + +async def test_effects(hass: HomeAssistant): + """Test effects.""" + yaml_configuration = { + DOMAIN: { + CONF_DEVICES: YAML_CONFIGURATION[DOMAIN][CONF_DEVICES], + CONF_CUSTOM_EFFECTS: [ + { + CONF_NAME: "mock_effect", + CONF_FLOW_PARAMS: { + ATTR_COUNT: 3, + ATTR_TRANSITIONS: [ + {YEELIGHT_HSV_TRANSACTION: [300, 50, 500, 50]}, + {YEELIGHT_RGB_TRANSITION: [100, 100, 100, 300, 30]}, + {YEELIGHT_TEMPERATURE_TRANSACTION: [3000, 200, 20]}, + {YEELIGHT_SLEEP_TRANSACTION: [800]}, + ], + }, + }, + ], + } + } + + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + assert await async_setup_component(hass, DOMAIN, yaml_configuration) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT).attributes.get( + "effect_list" + ) == 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 + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_EFFECT: name}, + blocking=True, + ) + if not called: + return + mocked_start_flow.assert_called_once() + if target is None: + return + args, _ = mocked_start_flow.call_args + flow = args[0] + assert flow.count == target.count + assert flow.action == target.action + assert str(flow.transitions) == str(target.transitions) + + effects = { + "mock_effect": Flow( + count=3, + transitions=[ + HSVTransition(300, 50, 500, 50), + RGBTransition(100, 100, 100, 300, 30), + TemperatureTransition(3000, 200, 20), + SleepTransition(800), + ], + ), + EFFECT_DISCO: Flow(transitions=transitions.disco()), + EFFECT_FAST_RANDOM_LOOP: None, + EFFECT_WHATSAPP: Flow(count=2, transitions=transitions.pulse(37, 211, 102)), + EFFECT_FACEBOOK: Flow(count=2, transitions=transitions.pulse(59, 89, 152)), + EFFECT_TWITTER: Flow(count=2, transitions=transitions.pulse(0, 172, 237)), + } + + for name, target in effects.items(): + await _async_test_effect(name, target) + await _async_test_effect("not_existed", called=False) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0e4d9aa904c..e8315b5dc75 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -4,7 +4,7 @@ from zeroconf import InterfaceChoice, IPVersion, ServiceInfo, ServiceStateChange from homeassistant.components import zeroconf from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE, CONF_IPV6 -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.generated import zeroconf as zc_gen from homeassistant.setup import async_setup_component @@ -90,6 +90,8 @@ async def test_setup(hass, mock_zeroconf): ) as mock_service_browser: mock_zeroconf.get_service_info.side_effect = get_service_info_mock assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 expected_flow_calls = 0 @@ -111,6 +113,8 @@ async def test_setup_with_default_interface(hass, mock_zeroconf): assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_DEFAULT_INTERFACE: True}} ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.Default) @@ -137,6 +141,8 @@ async def test_setup_without_ipv6(hass, mock_zeroconf): assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_IPV6: False}} ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert mock_zeroconf.called_with(ip_version=IPVersion.V4Only) @@ -150,6 +156,8 @@ async def test_setup_with_ipv6(hass, mock_zeroconf): assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_IPV6: True}} ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert mock_zeroconf.called_with() @@ -161,6 +169,8 @@ async def test_setup_with_ipv6_default(hass, mock_zeroconf): ): mock_zeroconf.get_service_info.side_effect = get_service_info_mock assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert mock_zeroconf.called_with() @@ -178,6 +188,8 @@ async def test_homekit_match_partial_space(hass, mock_zeroconf): "LIFX bulb", HOMEKIT_STATUS_UNPAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 @@ -197,6 +209,8 @@ async def test_homekit_match_partial_dash(hass, mock_zeroconf): "Rachio-fa46ba", HOMEKIT_STATUS_UNPAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 @@ -216,6 +230,8 @@ async def test_homekit_match_full(hass, mock_zeroconf): "BSB002", HOMEKIT_STATUS_UNPAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() homekit_mock = get_homekit_info_mock("BSB002", HOMEKIT_STATUS_UNPAIRED) info = homekit_mock("_hap._tcp.local.", "BSB002._hap._tcp.local.") @@ -240,6 +256,8 @@ async def test_homekit_already_paired(hass, mock_zeroconf): "tado", HOMEKIT_STATUS_PAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 2 @@ -260,6 +278,8 @@ async def test_homekit_invalid_paring_status(hass, mock_zeroconf): "tado", b"invalid" ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 @@ -279,6 +299,8 @@ async def test_homekit_not_paired(hass, mock_zeroconf): "this_will_not_match_any_integration", HOMEKIT_STATUS_UNPAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 6b94354ed59..bd340445527 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -403,7 +403,7 @@ async def async_test_level_on_off_from_hass( 4, (zigpy.types.uint8_t, zigpy.types.uint16_t), 10, - 0, + 1, expect_reply=True, manufacturer=None, tsn=None, diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index d1f141582ca..12b2c59ca81 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -119,7 +119,7 @@ async def test_erronous_network_key_fails_validation(hass, mock_openzwave): zwave.CONFIG_SCHEMA({"zwave": {"network_key": value}}) -async def test_auto_heal_midnight(hass, mock_openzwave): +async def test_auto_heal_midnight(hass, mock_openzwave, legacy_patchable_time): """Test network auto-heal at midnight.""" await async_setup_component(hass, "zwave", {"zwave": {"autoheal": True}}) await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index c3f600f9693..5c90dcb063e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Set up some common test helper things.""" import asyncio +import datetime import functools import logging import threading @@ -387,8 +388,71 @@ def legacy_patchable_time(): return async_unsub + @ha.callback + @loader.bind_hass + def async_track_utc_time_change( + hass, action, hour=None, minute=None, second=None, local=False + ): + """Add a listener that will fire if time matches a pattern.""" + # We do not have to wrap the function with time pattern matching logic + # if no pattern given + if all(val is None for val in (hour, minute, second)): + + @ha.callback + def time_change_listener(ev) -> None: + """Fire every time event that comes in.""" + hass.async_run_job(action, ev.data[ATTR_NOW]) + + return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener) + + matching_seconds = event.dt_util.parse_time_expression(second, 0, 59) + matching_minutes = event.dt_util.parse_time_expression(minute, 0, 59) + matching_hours = event.dt_util.parse_time_expression(hour, 0, 23) + + next_time = None + + def calculate_next(now) -> None: + """Calculate and set the next time the trigger should fire.""" + nonlocal next_time + + localized_now = event.dt_util.as_local(now) if local else now + next_time = event.dt_util.find_next_time_expression_time( + localized_now, matching_seconds, matching_minutes, matching_hours + ) + + # Make sure rolling back the clock doesn't prevent the timer from + # triggering. + last_now = None + + @ha.callback + def pattern_time_change_listener(ev) -> None: + """Listen for matching time_changed events.""" + nonlocal next_time, last_now + + now = ev.data[ATTR_NOW] + + if last_now is None or now < last_now: + # Time rolled back or next time not yet calculated + calculate_next(now) + + last_now = now + + if next_time <= now: + hass.async_run_job( + action, event.dt_util.as_local(now) if local else now + ) + calculate_next(now + datetime.timedelta(seconds=1)) + + # We can't use async_track_point_in_utc_time here because it would + # break in the case that the system time abruptly jumps backwards. + # Our custom last_now logic takes care of resolving that scenario. + return hass.bus.async_listen(EVENT_TIME_CHANGED, pattern_time_change_listener) + with patch( "homeassistant.helpers.event.async_track_point_in_utc_time", async_track_point_in_utc_time, + ), patch( + "homeassistant.helpers.event.async_track_utc_time_change", + async_track_utc_time_change, ): yield diff --git a/tests/fixtures/accuweather/current_conditions_data.json b/tests/fixtures/accuweather/current_conditions_data.json new file mode 100644 index 00000000000..f94ea071ee9 --- /dev/null +++ b/tests/fixtures/accuweather/current_conditions_data.json @@ -0,0 +1,290 @@ +{ + "WeatherIcon": 1, + "HasPrecipitation": false, + "PrecipitationType": null, + "Temperature": { + "Metric": { + "Value": 22.6, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 73.0, + "Unit": "F", + "UnitType": 18 + } + }, + "RealFeelTemperature": { + "Metric": { + "Value": 25.1, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 77.0, + "Unit": "F", + "UnitType": 18 + } + }, + "RealFeelTemperatureShade": { + "Metric": { + "Value": 21.1, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 70.0, + "Unit": "F", + "UnitType": 18 + } + }, + "RelativeHumidity": 67, + "IndoorRelativeHumidity": 67, + "DewPoint": { + "Metric": { + "Value": 16.2, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 61.0, + "Unit": "F", + "UnitType": 18 + } + }, + "Wind": { + "Direction": { + "Degrees": 180, + "Localized": "S", + "English": "S" + }, + "Speed": { + "Metric": { + "Value": 14.5, + "Unit": "km/h", + "UnitType": 7 + }, + "Imperial": { + "Value": 9.0, + "Unit": "mi/h", + "UnitType": 9 + } + } + }, + "WindGust": { + "Speed": { + "Metric": { + "Value": 20.3, + "Unit": "km/h", + "UnitType": 7 + }, + "Imperial": { + "Value": 12.6, + "Unit": "mi/h", + "UnitType": 9 + } + } + }, + "UVIndex": 6, + "UVIndexText": "High", + "Visibility": { + "Metric": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Imperial": { + "Value": 10.0, + "Unit": "mi", + "UnitType": 2 + } + }, + "ObstructionsToVisibility": "", + "CloudCover": 10, + "Ceiling": { + "Metric": { + "Value": 3200.0, + "Unit": "m", + "UnitType": 5 + }, + "Imperial": { + "Value": 10500.0, + "Unit": "ft", + "UnitType": 0 + } + }, + "Pressure": { + "Metric": { + "Value": 1012.0, + "Unit": "mb", + "UnitType": 14 + }, + "Imperial": { + "Value": 29.88, + "Unit": "inHg", + "UnitType": 12 + } + }, + "PressureTendency": { + "LocalizedText": "Falling", + "Code": "F" + }, + "Past24HourTemperatureDeparture": { + "Metric": { + "Value": 0.3, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 0.0, + "Unit": "F", + "UnitType": 18 + } + }, + "ApparentTemperature": { + "Metric": { + "Value": 22.8, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 73.0, + "Unit": "F", + "UnitType": 18 + } + }, + "WindChillTemperature": { + "Metric": { + "Value": 22.8, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 73.0, + "Unit": "F", + "UnitType": 18 + } + }, + "WetBulbTemperature": { + "Metric": { + "Value": 18.6, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 65.0, + "Unit": "F", + "UnitType": 18 + } + }, + "Precip1hr": { + "Metric": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.0, + "Unit": "in", + "UnitType": 1 + } + }, + "PrecipitationSummary": { + "Precipitation": { + "Metric": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.0, + "Unit": "in", + "UnitType": 1 + } + }, + "PastHour": { + "Metric": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.0, + "Unit": "in", + "UnitType": 1 + } + }, + "Past3Hours": { + "Metric": { + "Value": 1.3, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.05, + "Unit": "in", + "UnitType": 1 + } + }, + "Past6Hours": { + "Metric": { + "Value": 1.3, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.05, + "Unit": "in", + "UnitType": 1 + } + }, + "Past9Hours": { + "Metric": { + "Value": 2.5, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.1, + "Unit": "in", + "UnitType": 1 + } + }, + "Past12Hours": { + "Metric": { + "Value": 3.8, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.15, + "Unit": "in", + "UnitType": 1 + } + }, + "Past18Hours": { + "Metric": { + "Value": 5.1, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.2, + "Unit": "in", + "UnitType": 1 + } + }, + "Past24Hours": { + "Metric": { + "Value": 7.6, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.3, + "Unit": "in", + "UnitType": 1 + } + } + } +} \ No newline at end of file diff --git a/tests/fixtures/accuweather/location_data.json b/tests/fixtures/accuweather/location_data.json new file mode 100644 index 00000000000..43094d108ed --- /dev/null +++ b/tests/fixtures/accuweather/location_data.json @@ -0,0 +1,49 @@ +{ + "Version": 1, + "Key": "268068", + "Type": "City", + "Rank": 85, + "LocalizedName": "Piątek", + "EnglishName": "Piątek", + "PrimaryPostalCode": "", + "Region": { "ID": "EUR", "LocalizedName": "Europe", "EnglishName": "Europe" }, + "Country": { "ID": "PL", "LocalizedName": "Poland", "EnglishName": "Poland" }, + "AdministrativeArea": { + "ID": "10", + "LocalizedName": "Łódź", + "EnglishName": "Łódź", + "Level": 1, + "LocalizedType": "Voivodship", + "EnglishType": "Voivodship", + "CountryID": "PL" + }, + "TimeZone": { + "Code": "CEST", + "Name": "Europe/Warsaw", + "GmtOffset": 2.0, + "IsDaylightSaving": true, + "NextOffsetChange": "2020-10-25T01:00:00Z" + }, + "GeoPosition": { + "Latitude": 52.069, + "Longitude": 19.479, + "Elevation": { + "Metric": { "Value": 94.0, "Unit": "m", "UnitType": 5 }, + "Imperial": { "Value": 308.0, "Unit": "ft", "UnitType": 0 } + } + }, + "IsAlias": false, + "SupplementalAdminAreas": [ + { "Level": 2, "LocalizedName": "Łęczyca", "EnglishName": "Łęczyca" }, + { "Level": 3, "LocalizedName": "Piątek", "EnglishName": "Piątek" } + ], + "DataSets": [ + "AirQualityCurrentConditions", + "AirQualityForecasts", + "Alerts", + "ForecastConfidence", + "FutureRadar", + "MinuteCast", + "Radar" + ] +} diff --git a/tests/fixtures/ozw/generic_network_dump.csv b/tests/fixtures/ozw/generic_network_dump.csv index 9214796759a..a953121e881 100644 --- a/tests/fixtures/ozw/generic_network_dump.csv +++ b/tests/fixtures/ozw/generic_network_dump.csv @@ -279,4 +279,5 @@ OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "M OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} -OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} +OpenZWave/1/node/39/statistics/,{ "sendCount": 57, "sentFailed": 0, "retries": 1, "receivedPackets": 3594, "receivedDupPackets": 12, "receivedUnsolicited": 3546, "lastSentTimeStamp": 1595764791, "lastReceivedTimeStamp": 1595802261, "lastRequestRTT": 26, "averageRequestRTT": 29, "lastResponseRTT": 38, "averageResponseRTT": 37, "quality": 0, "extendedTXSupported": false, "txTime": 0, "hops": 0, "rssi_1": "", "rssi_2": "", "rssi_3": "", "rssi_4": "", "rssi_5": "", "route_1": 0, "route_2": 0, "route_3": 0, "route_4": 0, "ackChannel": 0, "lastTXChannel": 0, "routeScheme": "Idle", "routeUsed": "", "routeSpeed": "Auto", "routeTries": 0, "lastFailedLinkFrom": 0, "lastFailedLinkTo": 0} \ No newline at end of file diff --git a/tests/fixtures/ozw/light_new_ozw_network_dump.csv b/tests/fixtures/ozw/light_new_ozw_network_dump.csv new file mode 100644 index 00000000000..df810f64102 --- /dev/null +++ b/tests/fixtures/ozw/light_new_ozw_network_dump.csv @@ -0,0 +1,55 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1214", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} +OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1407375551070225/,{ "Label": "Dimming Duration", "Value": 255, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "Duration taken when changing the Level of a Device", "ValueIDKey": 1407375551070225, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/659128337/,{ "Label": "Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 39, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 659128337, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/281475635839000/,{ "Label": "Bright", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 39, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475635839000, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/562950612549656/,{ "Label": "Dim", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 39, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562950612549656, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/844425597648912/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844425597648912, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1125900574359569/,{ "Label": "Start Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125900574359569, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/value/667533332/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 667533332, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/,{ "Instance": 1, "CommandClassId": 51, "CommandClass": "COMMAND_CLASS_COLOR", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/562950621151251/,{ "Label": "Color Channels", "Value": 31, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 2, "Node": 39, "Genre": "System", "Help": "Color Capabilities of the device", "ValueIDKey": 562950621151251, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/,{ "Label": "Color", "Value": "#000000FF00", "Units": "#RRGGBBWWCW", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 0, "Node": 39, "Genre": "User", "Help": "Color (in RGB format)", "ValueIDKey": 659341335, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/281475636051988/,{ "Label": "Color Index", "Value": { "List": [ { "Value": 0, "Label": "Off" }, { "Value": 1, "Label": "Cool White" }, { "Value": 2, "Label": "Warm White" }, { "Value": 3, "Label": "Red" }, { "Value": 4, "Label": "Lime" }, { "Value": 5, "Label": "Blue" }, { "Value": 6, "Label": "Yellow" }, { "Value": 7, "Label": "Cyan" }, { "Value": 8, "Label": "Magenta" }, { "Value": 9, "Label": "Silver" }, { "Value": 10, "Label": "Gray" }, { "Value": 11, "Label": "Maroon" }, { "Value": 12, "Label": "Olive" }, { "Value": 13, "Label": "Green" }, { "Value": 14, "Label": "Purple" }, { "Value": 15, "Label": "Teal" }, { "Value": 16, "Label": "Navy" }, { "Value": 17, "Label": "Custom" } ], "Selected": "Warm White" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 1, "Node": 39, "Genre": "User", "Help": "Preset Color", "ValueIDKey": 281475636051988, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/,{ "Instance": 1, "CommandClassId": 94, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/668434449/,{ "Label": "ZWave+ Version", "Value": 1, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 0, "Node": 39, "Genre": "System", "Help": "ZWave+ Version Supported on the Device", "ValueIDKey": 668434449, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/281475645145110/,{ "Label": "InstallerIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 1, "Node": 39, "Genre": "System", "Help": "Icon File to use for the Installer Application", "ValueIDKey": 281475645145110, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/562950621855766/,{ "Label": "UserIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 2, "Node": 39, "Genre": "System", "Help": "Icon File to use for the User Application", "ValueIDKey": 562950621855766, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/281475641245716/,{ "Label": "User custom mode LED animations", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Blink Colors in order mode" }, { "Value": 2, "Label": "Randomized blink color mode" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 1, "Node": 39, "Genre": "Config", "Help": "User custom mode for LED animations", "ValueIDKey": 281475641245716, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/562950617956372/,{ "Label": "Strobe over Custom Color", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Enable" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 2, "Node": 39, "Genre": "Config", "Help": "Enable/Disable Strobe over Custom Color.", "ValueIDKey": 562950617956372, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/844425594667027/,{ "Label": "Set the rate of change to next color in Custom Mode", "Value": 50, "Units": "ms", "Min": 5, "Max": 8640000, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 39, "Genre": "Config", "Help": "Set the rate of change to next color in Custom Mode.", "ValueIDKey": 844425594667027, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/1125900571377681/,{ "Label": "Set color that LED Bulb blinks", "Value": 1, "Units": "", "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 39, "Genre": "Config", "Help": "Set color that LED Bulb blinks in Blink Mode.", "ValueIDKey": 1125900571377681, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/4503600291905553/,{ "Label": "Ramp rate when dimming using Multilevel Switch", "Value": 20, "Units": "100ms", "Min": 0, "Max": 100, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 16, "Node": 39, "Genre": "Config", "Help": "Specifying the ramp rate when dimming using Multilevel Switch V1 CC in 100ms.", "ValueIDKey": 4503600291905553, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22517998801387540/,{ "Label": "Notification", "Value": { "List": [ { "Value": 0, "Label": "Nothing" }, { "Value": 1, "Label": "Basic CC report" } ], "Selected": "Basic CC report" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 80, "Node": 39, "Genre": "Config", "Help": "Enable to send notifications to associated devices (Group 1) when the state of LED Bulb is changed.", "ValueIDKey": 22517998801387540, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22799473778098198/,{ "Label": "Warm White temperature", "Value": 2700, "Units": "k", "Min": 2700, "Max": 4999, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 81, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in warm white color component. available value: 2700k to 4999k", "ValueIDKey": 22799473778098198, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/23080948754808854/,{ "Label": "cold white temperature", "Value": 6500, "Units": "k", "Min": 5000, "Max": 6500, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 82, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in cold white color component. available value:5000k to 6500k", "ValueIDKey": 23080948754808854, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/668762131/,{ "Label": "Loaded Config Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 39, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 668762131, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/281475645472787/,{ "Label": "Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 39, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475645472787, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/562950622183443/,{ "Label": "Latest Available Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 39, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950622183443, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/844425598894103/,{ "Label": "Device ID", "Value": "", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 39, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844425598894103, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/1125900575604759/,{ "Label": "Serial Number", "Value": "00001cd6bda18c83", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 39, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125900575604759, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/668778516/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 668778516, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/281475645489169/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 39, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475645489169, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/562950622199832/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 39, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950622199832, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/844425598910481/,{ "Label": "Test Node", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844425598910481, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1125900575621140/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125900575621140, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1407375552331798/,{ "Label": "Frame Count", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "How Many Messages to send to the Note for the Test", "ValueIDKey": 1407375552331798, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1688850529042456/,{ "Label": "Test", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 39, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688850529042456, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1970325505753112/,{ "Label": "Report", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 39, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970325505753112, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2251800482463764/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 39, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251800482463764, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2533275459174422/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 39, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533275459174422, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/669089815/,{ "Label": "Library Version", "Value": "3", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 39, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 669089815, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/281475645800471/,{ "Label": "Protocol Version", "Value": "4.38", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 39, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475645800471, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/562950622511127/,{ "Label": "Application Version", "Value": "2.00", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 39, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950622511127, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "MaxAssociations": 1, "Members": [ "1.0" ], "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} +OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file diff --git a/tests/fixtures/ozw/light_no_cw_network_dump.csv b/tests/fixtures/ozw/light_no_cw_network_dump.csv new file mode 100644 index 00000000000..4120bc34dce --- /dev/null +++ b/tests/fixtures/ozw/light_no_cw_network_dump.csv @@ -0,0 +1,54 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} +OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/659128337/,{ "Label": "Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 39, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 659128337, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/281475635839000/,{ "Label": "Bright", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 39, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475635839000, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/562950612549656/,{ "Label": "Dim", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 39, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562950612549656, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/844425597648912/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844425597648912, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1125900574359569/,{ "Label": "Start Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125900574359569, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/value/667533332/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 667533332, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/,{ "Instance": 1, "CommandClassId": 51, "CommandClass": "COMMAND_CLASS_COLOR", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/562950621151251/,{ "Label": "Color Channels", "Value": 29, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 2, "Node": 39, "Genre": "System", "Help": "Color Capabilities of the device", "ValueIDKey": 562950621151251, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/,{ "Label": "Color", "Value": "#0000000000", "Units": "#RRGGBBWWCW", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 0, "Node": 39, "Genre": "User", "Help": "Color (in RGB format)", "ValueIDKey": 659341335, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/281475636051988/,{ "Label": "Color Index", "Value": { "List": [ { "Value": 0, "Label": "Off" }, { "Value": 1, "Label": "Cool White" }, { "Value": 2, "Label": "Warm White" }, { "Value": 3, "Label": "Red" }, { "Value": 4, "Label": "Lime" }, { "Value": 5, "Label": "Blue" }, { "Value": 6, "Label": "Yellow" }, { "Value": 7, "Label": "Cyan" }, { "Value": 8, "Label": "Magenta" }, { "Value": 9, "Label": "Silver" }, { "Value": 10, "Label": "Gray" }, { "Value": 11, "Label": "Maroon" }, { "Value": 12, "Label": "Olive" }, { "Value": 13, "Label": "Green" }, { "Value": 14, "Label": "Purple" }, { "Value": 15, "Label": "Teal" }, { "Value": 16, "Label": "Navy" }, { "Value": 17, "Label": "Custom" } ], "Selected": "Warm White" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 1, "Node": 39, "Genre": "User", "Help": "Preset Color", "ValueIDKey": 281475636051988, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/,{ "Instance": 1, "CommandClassId": 94, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/668434449/,{ "Label": "ZWave+ Version", "Value": 1, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 0, "Node": 39, "Genre": "System", "Help": "ZWave+ Version Supported on the Device", "ValueIDKey": 668434449, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/281475645145110/,{ "Label": "InstallerIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 1, "Node": 39, "Genre": "System", "Help": "Icon File to use for the Installer Application", "ValueIDKey": 281475645145110, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/562950621855766/,{ "Label": "UserIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 2, "Node": 39, "Genre": "System", "Help": "Icon File to use for the User Application", "ValueIDKey": 562950621855766, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/281475641245716/,{ "Label": "User custom mode LED animations", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Blink Colors in order mode" }, { "Value": 2, "Label": "Randomized blink color mode" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 1, "Node": 39, "Genre": "Config", "Help": "User custom mode for LED animations", "ValueIDKey": 281475641245716, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/562950617956372/,{ "Label": "Strobe over Custom Color", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Enable" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 2, "Node": 39, "Genre": "Config", "Help": "Enable/Disable Strobe over Custom Color.", "ValueIDKey": 562950617956372, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/844425594667027/,{ "Label": "Set the rate of change to next color in Custom Mode", "Value": 50, "Units": "ms", "Min": 5, "Max": 8640000, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 39, "Genre": "Config", "Help": "Set the rate of change to next color in Custom Mode.", "ValueIDKey": 844425594667027, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/1125900571377681/,{ "Label": "Set color that LED Bulb blinks", "Value": 1, "Units": "", "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 39, "Genre": "Config", "Help": "Set color that LED Bulb blinks in Blink Mode.", "ValueIDKey": 1125900571377681, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/4503600291905553/,{ "Label": "Ramp rate when dimming using Multilevel Switch", "Value": 20, "Units": "100ms", "Min": 0, "Max": 100, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 16, "Node": 39, "Genre": "Config", "Help": "Specifying the ramp rate when dimming using Multilevel Switch V1 CC in 100ms.", "ValueIDKey": 4503600291905553, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22517998801387540/,{ "Label": "Notification", "Value": { "List": [ { "Value": 0, "Label": "Nothing" }, { "Value": 1, "Label": "Basic CC report" } ], "Selected": "Basic CC report" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 80, "Node": 39, "Genre": "Config", "Help": "Enable to send notifications to associated devices (Group 1) when the state of LED Bulb is changed.", "ValueIDKey": 22517998801387540, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22799473778098198/,{ "Label": "Warm White temperature", "Value": 2700, "Units": "k", "Min": 2700, "Max": 4999, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 81, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in warm white color component. available value: 2700k to 4999k", "ValueIDKey": 22799473778098198, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/23080948754808854/,{ "Label": "cold white temperature", "Value": 6500, "Units": "k", "Min": 5000, "Max": 6500, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 82, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in cold white color component. available value:5000k to 6500k", "ValueIDKey": 23080948754808854, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/668762131/,{ "Label": "Loaded Config Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 39, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 668762131, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/281475645472787/,{ "Label": "Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 39, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475645472787, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/562950622183443/,{ "Label": "Latest Available Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 39, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950622183443, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/844425598894103/,{ "Label": "Device ID", "Value": "", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 39, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844425598894103, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/1125900575604759/,{ "Label": "Serial Number", "Value": "00001cd6bda18c83", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 39, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125900575604759, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/668778516/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 668778516, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/281475645489169/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 39, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475645489169, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/562950622199832/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 39, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950622199832, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/844425598910481/,{ "Label": "Test Node", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844425598910481, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1125900575621140/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125900575621140, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1407375552331798/,{ "Label": "Frame Count", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "How Many Messages to send to the Note for the Test", "ValueIDKey": 1407375552331798, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1688850529042456/,{ "Label": "Test", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 39, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688850529042456, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1970325505753112/,{ "Label": "Report", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 39, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970325505753112, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2251800482463764/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 39, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251800482463764, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2533275459174422/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 39, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533275459174422, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/669089815/,{ "Label": "Library Version", "Value": "3", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 39, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 669089815, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/281475645800471/,{ "Label": "Protocol Version", "Value": "4.38", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 39, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475645800471, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/562950622511127/,{ "Label": "Application Version", "Value": "2.00", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 39, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950622511127, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "MaxAssociations": 1, "Members": [ "1.0" ], "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} +OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file diff --git a/tests/fixtures/ozw/light_no_rgb.json b/tests/fixtures/ozw/light_no_rgb.json new file mode 100644 index 00000000000..85226b8a71a --- /dev/null +++ b/tests/fixtures/ozw/light_no_rgb.json @@ -0,0 +1,25 @@ +{ + "topic": "OpenZWave/1/node/2/instance/1/commandclass/38/value/38371345/", + "payload": { + "Label": "Level", + "Value": 0, + "Units": "", + "Min": 0, + "Max": 255, + "Type": "Byte", + "Instance": 1, + "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", + "Index": 0, + "Node": 2, + "Genre": "User", + "Help": "The Current Level of the Device", + "ValueIDKey": 38371345, + "ReadOnly": false, + "WriteOnly": false, + "ValueSet": false, + "ValuePolled": false, + "ChangeVerified": false, + "Event": "valueAdded", + "TimeStamp": 1579566891 + } +} diff --git a/tests/fixtures/ozw/light_no_rgb_network_dump.csv b/tests/fixtures/ozw/light_no_rgb_network_dump.csv new file mode 100644 index 00000000000..6febaab3667 --- /dev/null +++ b/tests/fixtures/ozw/light_no_rgb_network_dump.csv @@ -0,0 +1,41 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/2/,{ "NodeID": 2, "NodeQueryStage": "Complete", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0063:3031:4944", "ZWAProductURL": "", "ProductPic": "images/ge/12724-dimmer.png", "Description": "Transform any home into a smart home with the GE Z-Wave Smart Fan Control. The in-wall fan control easily replaces any standard in-wall switch remotely controls a ceiling fan in your home and features a three-speed control system. Your home will be equipped with ultimate flexibility with the GE Z-Wave Smart Fan Control, capable of being used by itself or with up to four GE add-on switches. Screw terminal installation provides improved space efficiency when replacing existing switches and the integrated LED indicator light allows you to easily locate the switch in a dark room. The GE Z-Wave Smart Fan Control is compatible with any Z-Wave certified gateway, providing access to many popular home automation systems. Take control of your home lighting with GE Z-Wave Smart Lighting Controls!", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2506/Binder2.pdf", "ProductPageURL": "http://www.ezzwave.com", "InclusionHelp": "1. Follow the instructions for your Z-Wave certified controller to include a device to the Z-Wave network. 2. Once the controller is ready to include your device, press and release the top or bottom of the smart fan control switch (rocker) to include it in the network. 3. Once your controller has confirmed the device has been included, refresh the Z-Wave network to optimize performance.", "ExclusionHelp": "1. Follow the instructions for your Z-Wave certified controller to exclude a device from the Z-Wave network. 2. Once the controller is ready to Exclude your device, press and release the top or bottom of the wireless smart switch (rocker) to exclude it from the network.", "ResetHelp": "1. Quickly press ON (Top) button three (3) times then immediately press the OFF (Bottom) button three (3) times. The LED will flash ON/OFF 5 times when completed successfully. Note: This should only be used in the event your network’s primary controller is missing or otherwise inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "In-Wall Smart Fan Control", "ProductPicBase64": "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" }, "Event": "nodeQueriesComplete", "TimeStamp": 1594407756, "NodeManufacturerName": "GE (Jasco Products)", "NodeProductName": "12724 3-Way Dimmer Switch", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0063", "NodeProductType": "0x4944", "NodeProductID": "0x3031", "NodeBaudRate": 40000, "NodeVersion": 4, "NodeGroups": 0, "NodeName": "Master_Bedroom_L", "NodeLocation": "Master Bedroom", "NodeDeviceTypeString": "Unknown Type (0x0000)", "NodeDeviceType": 0, "NodeRole": 0, "NodeRoleString": "Central Controller", "NodePlusType": 0, "NodePlusTypeString": "Z-Wave+ node", "Neighbors": [ 1, 6, 9, 10, 11, 12, 15, 16, 17, 18, 19, 20, 21, 23, 24, 26, 27, 28, 29, 30, 33 ], "Neighbors": [ 1, 6, 9, 10, 11, 12, 15, 16, 17, 18, 19, 20, 21, 23, 24, 26, 27, 28, 29, 30, 33 ]} +OpenZWave/1/node/2/instance/1/,{ "Instance": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/112/value/844424973910036/,{ "Label": "LED Light", "Value": { "List": [ { "Value": 0, "Label": "LED on when light off" }, { "Value": 1, "Label": "LED on when light on" }, { "Value": 2, "Label": "LED always off" } ], "Selected": "LED on when light on", "Selected_id": 1 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 2, "Genre": "Config", "Help": "Sets when the LED on the switch is lit.", "ValueIDKey": 844424973910036, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407754} +OpenZWave/1/node/2/instance/1/commandclass/112/value/1125899950620692/,{ "Label": "Invert Switch", "Value": { "List": [ { "Value": 0, "Label": "No" }, { "Value": 1, "Label": "Yes" } ], "Selected": "No", "Selected_id": 0 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 2, "Genre": "Config", "Help": "Change the top of the switch to OFF and the bottom of the switch to ON, if the switch was installed upside down.", "ValueIDKey": 1125899950620692, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407754} +OpenZWave/1/node/2/instance/1/commandclass/112/value/1970324880752657/,{ "Label": "Z-Wave Command Dim Step", "Value": 1, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 99, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 7, "Node": 2, "Genre": "Config", "Help": "Indicates how many levels the dimmer will change for each dimming step.", "ValueIDKey": 1970324880752657, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/2251799857463313/,{ "Label": "Z-Wave Command Dim Rate", "Value": 1, "Units": "x 10 milliseconds", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 8, "Node": 2, "Genre": "Config", "Help": "This value indicates in 10 millisecond resolution, how often the dim level will change. For example, if you set this parameter to 1, then every 10ms the dim level will change. If you set it to 255, then every 2.55 seconds the dim level will change.", "ValueIDKey": 2251799857463313, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/2533274834173969/,{ "Label": "Local Control Dim Step", "Value": 1, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 99, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 9, "Node": 2, "Genre": "Config", "Help": "Indicates how many levels the dimmer will change for each dimming step.", "ValueIDKey": 2533274834173969, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/2814749810884625/,{ "Label": "Local Control Dim Rate", "Value": 5, "Units": "x 10 milliseconds", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 10, "Node": 2, "Genre": "Config", "Help": "This value indicates in 10 millisecond resolution, how often the dim level will change. For example, if you set this parameter to 1, then every 10ms the dim level will change. If you set it to 255, then every 2.55 seconds the dim level will change.", "ValueIDKey": 2814749810884625, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/3096224787595281/,{ "Label": "ALL ON/ALL OFF Dim Step", "Value": 99, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 99, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 11, "Node": 2, "Genre": "Config", "Help": "Indicates how many levels the dimmer will change for each dimming step.", "ValueIDKey": 3096224787595281, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/3377699764305937/,{ "Label": "ALL ON/ALL OFF Dim Rate", "Value": 5, "Units": "x 10 milliseconds", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 12, "Node": 2, "Genre": "Config", "Help": "This value indicates in 10 millisecond resolution, how often the dim level will change. For example, if you set this parameter to 1, then every 10ms the dim level will change. If you set it to 255, then every 2.55 seconds the dim level will change.", "ValueIDKey": 3377699764305937, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407756} +OpenZWave/1/node/2/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/38/value/38371345/,{ "Label": "Level", "Value": 0, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 2, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 38371345, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407637} +OpenZWave/1/node/2/instance/1/commandclass/38/value/281475015082008/,{ "Label": "Bright", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 2, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475015082008, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/38/value/562949991792664/,{ "Label": "Dim", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 2, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562949991792664, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/38/value/844424976891920/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 2, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844424976891920, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/38/value/1125899953602577/,{ "Label": "Start Level", "Value": 0, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 2, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125899953602577, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/39/value/46776340/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled", "Selected_id": 255 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 2, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 46776340, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407636} +OpenZWave/1/node/2/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/48005139/,{ "Label": "Loaded Config Revision", "Value": 9, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 2, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 48005139, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/281475024715795/,{ "Label": "Config File Revision", "Value": 9, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 2, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475024715795, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/562950001426451/,{ "Label": "Latest Available Config File Revision", "Value": 9, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 2, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950001426451, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/844424978137111/,{ "Label": "Device ID", "Value": "", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 2, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844424978137111, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/1125899954847767/,{ "Label": "Serial Number", "Value": "", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 2, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125899954847767, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/48021524/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal", "Selected_id": 0 }, "Units": "dB", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 2, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 48021524, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407636} +OpenZWave/1/node/2/instance/1/commandclass/115/value/281475024732177/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 2, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475024732177, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407636} +OpenZWave/1/node/2/instance/1/commandclass/115/value/562950001442840/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 2, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950001442840, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/844424978153489/,{ "Label": "Test Node", "Value": 0, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 2, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844424978153489, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/1125899954864148/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal", "Selected_id": 0 }, "Units": "dB", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 2, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125899954864148, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/1407374931574806/,{ "Label": "Frame Count", "Value": 0, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 2, "Genre": "System", "Help": "How Many Messages to send to the Node for the Test", "ValueIDKey": 1407374931574806, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/1688849908285464/,{ "Label": "Test", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 2, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688849908285464, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/1970324884996120/,{ "Label": "Report", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 2, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970324884996120, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/2251799861706772/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed", "Selected_id": 0 }, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 2, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251799861706772, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/2533274838417430/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 2, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533274838417430, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/134/value/48332823/,{ "Label": "Library Version", "Value": "6", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 2, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 48332823, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/134/value/281475025043479/,{ "Label": "Protocol Version", "Value": "3.67", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 2, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475025043479, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/134/value/562950001754135/,{ "Label": "Application Version", "Value": "3.37", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 2, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950001754135, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} diff --git a/tests/fixtures/ozw/light_no_ww_network_dump.csv b/tests/fixtures/ozw/light_no_ww_network_dump.csv new file mode 100644 index 00000000000..c001750973d --- /dev/null +++ b/tests/fixtures/ozw/light_no_ww_network_dump.csv @@ -0,0 +1,54 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} +OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/659128337/,{ "Label": "Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 39, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 659128337, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/281475635839000/,{ "Label": "Bright", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 39, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475635839000, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/562950612549656/,{ "Label": "Dim", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 39, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562950612549656, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/844425597648912/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844425597648912, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1125900574359569/,{ "Label": "Start Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125900574359569, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/value/667533332/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 667533332, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/,{ "Instance": 1, "CommandClassId": 51, "CommandClass": "COMMAND_CLASS_COLOR", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/562950621151251/,{ "Label": "Color Channels", "Value": 30, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 2, "Node": 39, "Genre": "System", "Help": "Color Capabilities of the device", "ValueIDKey": 562950621151251, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/,{ "Label": "Color", "Value": "#0000000000", "Units": "#RRGGBBWWCW", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 0, "Node": 39, "Genre": "User", "Help": "Color (in RGB format)", "ValueIDKey": 659341335, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/281475636051988/,{ "Label": "Color Index", "Value": { "List": [ { "Value": 0, "Label": "Off" }, { "Value": 1, "Label": "Cool White" }, { "Value": 2, "Label": "Warm White" }, { "Value": 3, "Label": "Red" }, { "Value": 4, "Label": "Lime" }, { "Value": 5, "Label": "Blue" }, { "Value": 6, "Label": "Yellow" }, { "Value": 7, "Label": "Cyan" }, { "Value": 8, "Label": "Magenta" }, { "Value": 9, "Label": "Silver" }, { "Value": 10, "Label": "Gray" }, { "Value": 11, "Label": "Maroon" }, { "Value": 12, "Label": "Olive" }, { "Value": 13, "Label": "Green" }, { "Value": 14, "Label": "Purple" }, { "Value": 15, "Label": "Teal" }, { "Value": 16, "Label": "Navy" }, { "Value": 17, "Label": "Custom" } ], "Selected": "Warm White" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 1, "Node": 39, "Genre": "User", "Help": "Preset Color", "ValueIDKey": 281475636051988, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/,{ "Instance": 1, "CommandClassId": 94, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/668434449/,{ "Label": "ZWave+ Version", "Value": 1, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 0, "Node": 39, "Genre": "System", "Help": "ZWave+ Version Supported on the Device", "ValueIDKey": 668434449, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/281475645145110/,{ "Label": "InstallerIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 1, "Node": 39, "Genre": "System", "Help": "Icon File to use for the Installer Application", "ValueIDKey": 281475645145110, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/562950621855766/,{ "Label": "UserIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 2, "Node": 39, "Genre": "System", "Help": "Icon File to use for the User Application", "ValueIDKey": 562950621855766, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/281475641245716/,{ "Label": "User custom mode LED animations", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Blink Colors in order mode" }, { "Value": 2, "Label": "Randomized blink color mode" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 1, "Node": 39, "Genre": "Config", "Help": "User custom mode for LED animations", "ValueIDKey": 281475641245716, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/562950617956372/,{ "Label": "Strobe over Custom Color", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Enable" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 2, "Node": 39, "Genre": "Config", "Help": "Enable/Disable Strobe over Custom Color.", "ValueIDKey": 562950617956372, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/844425594667027/,{ "Label": "Set the rate of change to next color in Custom Mode", "Value": 50, "Units": "ms", "Min": 5, "Max": 8640000, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 39, "Genre": "Config", "Help": "Set the rate of change to next color in Custom Mode.", "ValueIDKey": 844425594667027, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/1125900571377681/,{ "Label": "Set color that LED Bulb blinks", "Value": 1, "Units": "", "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 39, "Genre": "Config", "Help": "Set color that LED Bulb blinks in Blink Mode.", "ValueIDKey": 1125900571377681, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/4503600291905553/,{ "Label": "Ramp rate when dimming using Multilevel Switch", "Value": 20, "Units": "100ms", "Min": 0, "Max": 100, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 16, "Node": 39, "Genre": "Config", "Help": "Specifying the ramp rate when dimming using Multilevel Switch V1 CC in 100ms.", "ValueIDKey": 4503600291905553, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22517998801387540/,{ "Label": "Notification", "Value": { "List": [ { "Value": 0, "Label": "Nothing" }, { "Value": 1, "Label": "Basic CC report" } ], "Selected": "Basic CC report" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 80, "Node": 39, "Genre": "Config", "Help": "Enable to send notifications to associated devices (Group 1) when the state of LED Bulb is changed.", "ValueIDKey": 22517998801387540, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22799473778098198/,{ "Label": "Warm White temperature", "Value": 2700, "Units": "k", "Min": 2700, "Max": 4999, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 81, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in warm white color component. available value: 2700k to 4999k", "ValueIDKey": 22799473778098198, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/23080948754808854/,{ "Label": "cold white temperature", "Value": 6500, "Units": "k", "Min": 5000, "Max": 6500, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 82, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in cold white color component. available value:5000k to 6500k", "ValueIDKey": 23080948754808854, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/668762131/,{ "Label": "Loaded Config Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 39, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 668762131, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/281475645472787/,{ "Label": "Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 39, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475645472787, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/562950622183443/,{ "Label": "Latest Available Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 39, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950622183443, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/844425598894103/,{ "Label": "Device ID", "Value": "", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 39, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844425598894103, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/1125900575604759/,{ "Label": "Serial Number", "Value": "00001cd6bda18c83", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 39, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125900575604759, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/668778516/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 668778516, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/281475645489169/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 39, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475645489169, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/562950622199832/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 39, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950622199832, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/844425598910481/,{ "Label": "Test Node", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844425598910481, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1125900575621140/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125900575621140, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1407375552331798/,{ "Label": "Frame Count", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "How Many Messages to send to the Note for the Test", "ValueIDKey": 1407375552331798, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1688850529042456/,{ "Label": "Test", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 39, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688850529042456, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1970325505753112/,{ "Label": "Report", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 39, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970325505753112, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2251800482463764/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 39, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251800482463764, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2533275459174422/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 39, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533275459174422, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/669089815/,{ "Label": "Library Version", "Value": "3", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 39, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 669089815, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/281475645800471/,{ "Label": "Protocol Version", "Value": "4.38", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 39, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475645800471, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/562950622511127/,{ "Label": "Application Version", "Value": "2.00", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 39, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950622511127, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "MaxAssociations": 1, "Members": [ "1.0" ], "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} +OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file diff --git a/tests/fixtures/ozw/light_rgb.json b/tests/fixtures/ozw/light_rgb.json new file mode 100644 index 00000000000..0945b77db2d --- /dev/null +++ b/tests/fixtures/ozw/light_rgb.json @@ -0,0 +1,25 @@ +{ + "topic": "OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/", + "payload": { + "Label": "Color", + "Value": "#000000FF00", + "Units": "#RRGGBBWWCW", + "Min": 0, + "Max": 0, + "Type": "String", + "Instance": 1, + "CommandClass": "COMMAND_CLASS_COLOR", + "Index": 0, + "Node": 39, + "Genre": "User", + "Help": "Color (in RGB format)", + "ValueIDKey": 659341335, + "ReadOnly": false, + "WriteOnly": false, + "ValueSet": false, + "ValuePolled": false, + "ChangeVerified": false, + "Event": "valueAdded", + "TimeStamp": 1579566891 + } +} diff --git a/tests/fixtures/ozw/light_wc_network_dump.csv b/tests/fixtures/ozw/light_wc_network_dump.csv new file mode 100644 index 00000000000..7af15f9926a --- /dev/null +++ b/tests/fixtures/ozw/light_wc_network_dump.csv @@ -0,0 +1,54 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1214", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} +OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/659128337/,{ "Label": "Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 39, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 659128337, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/281475635839000/,{ "Label": "Bright", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 39, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475635839000, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/562950612549656/,{ "Label": "Dim", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 39, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562950612549656, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/844425597648912/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844425597648912, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1125900574359569/,{ "Label": "Start Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125900574359569, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/value/667533332/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 667533332, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/,{ "Instance": 1, "CommandClassId": 51, "CommandClass": "COMMAND_CLASS_COLOR", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/562950621151251/,{ "Label": "Color Channels", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 2, "Node": 39, "Genre": "System", "Help": "Color Capabilities of the device", "ValueIDKey": 562950621151251, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/,{ "Label": "Color", "Value": "#0000000000", "Units": "#RRGGBBWWCW", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 0, "Node": 39, "Genre": "User", "Help": "Color (in RGB format)", "ValueIDKey": 659341335, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/281475636051988/,{ "Label": "Color Index", "Value": { "List": [ { "Value": 0, "Label": "Off" }, { "Value": 1, "Label": "Cool White" }, { "Value": 2, "Label": "Warm White" }, { "Value": 3, "Label": "Red" }, { "Value": 4, "Label": "Lime" }, { "Value": 5, "Label": "Blue" }, { "Value": 6, "Label": "Yellow" }, { "Value": 7, "Label": "Cyan" }, { "Value": 8, "Label": "Magenta" }, { "Value": 9, "Label": "Silver" }, { "Value": 10, "Label": "Gray" }, { "Value": 11, "Label": "Maroon" }, { "Value": 12, "Label": "Olive" }, { "Value": 13, "Label": "Green" }, { "Value": 14, "Label": "Purple" }, { "Value": 15, "Label": "Teal" }, { "Value": 16, "Label": "Navy" }, { "Value": 17, "Label": "Custom" } ], "Selected": "Warm White" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 1, "Node": 39, "Genre": "User", "Help": "Preset Color", "ValueIDKey": 281475636051988, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/,{ "Instance": 1, "CommandClassId": 94, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/668434449/,{ "Label": "ZWave+ Version", "Value": 1, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 0, "Node": 39, "Genre": "System", "Help": "ZWave+ Version Supported on the Device", "ValueIDKey": 668434449, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/281475645145110/,{ "Label": "InstallerIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 1, "Node": 39, "Genre": "System", "Help": "Icon File to use for the Installer Application", "ValueIDKey": 281475645145110, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/562950621855766/,{ "Label": "UserIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 2, "Node": 39, "Genre": "System", "Help": "Icon File to use for the User Application", "ValueIDKey": 562950621855766, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/281475641245716/,{ "Label": "User custom mode LED animations", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Blink Colors in order mode" }, { "Value": 2, "Label": "Randomized blink color mode" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 1, "Node": 39, "Genre": "Config", "Help": "User custom mode for LED animations", "ValueIDKey": 281475641245716, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/562950617956372/,{ "Label": "Strobe over Custom Color", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Enable" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 2, "Node": 39, "Genre": "Config", "Help": "Enable/Disable Strobe over Custom Color.", "ValueIDKey": 562950617956372, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/844425594667027/,{ "Label": "Set the rate of change to next color in Custom Mode", "Value": 50, "Units": "ms", "Min": 5, "Max": 8640000, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 39, "Genre": "Config", "Help": "Set the rate of change to next color in Custom Mode.", "ValueIDKey": 844425594667027, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/1125900571377681/,{ "Label": "Set color that LED Bulb blinks", "Value": 1, "Units": "", "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 39, "Genre": "Config", "Help": "Set color that LED Bulb blinks in Blink Mode.", "ValueIDKey": 1125900571377681, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/4503600291905553/,{ "Label": "Ramp rate when dimming using Multilevel Switch", "Value": 20, "Units": "100ms", "Min": 0, "Max": 100, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 16, "Node": 39, "Genre": "Config", "Help": "Specifying the ramp rate when dimming using Multilevel Switch V1 CC in 100ms.", "ValueIDKey": 4503600291905553, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22517998801387540/,{ "Label": "Notification", "Value": { "List": [ { "Value": 0, "Label": "Nothing" }, { "Value": 1, "Label": "Basic CC report" } ], "Selected": "Basic CC report" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 80, "Node": 39, "Genre": "Config", "Help": "Enable to send notifications to associated devices (Group 1) when the state of LED Bulb is changed.", "ValueIDKey": 22517998801387540, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22799473778098198/,{ "Label": "Warm White temperature", "Value": 2700, "Units": "k", "Min": 2700, "Max": 4999, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 81, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in warm white color component. available value: 2700k to 4999k", "ValueIDKey": 22799473778098198, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/23080948754808854/,{ "Label": "cold white temperature", "Value": 6500, "Units": "k", "Min": 5000, "Max": 6500, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 82, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in cold white color component. available value:5000k to 6500k", "ValueIDKey": 23080948754808854, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/668762131/,{ "Label": "Loaded Config Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 39, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 668762131, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/281475645472787/,{ "Label": "Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 39, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475645472787, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/562950622183443/,{ "Label": "Latest Available Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 39, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950622183443, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/844425598894103/,{ "Label": "Device ID", "Value": "", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 39, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844425598894103, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/1125900575604759/,{ "Label": "Serial Number", "Value": "00001cd6bda18c83", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 39, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125900575604759, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/668778516/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 668778516, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/281475645489169/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 39, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475645489169, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/562950622199832/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 39, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950622199832, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/844425598910481/,{ "Label": "Test Node", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844425598910481, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1125900575621140/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125900575621140, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1407375552331798/,{ "Label": "Frame Count", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "How Many Messages to send to the Note for the Test", "ValueIDKey": 1407375552331798, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1688850529042456/,{ "Label": "Test", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 39, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688850529042456, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1970325505753112/,{ "Label": "Report", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 39, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970325505753112, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2251800482463764/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 39, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251800482463764, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2533275459174422/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 39, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533275459174422, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/669089815/,{ "Label": "Library Version", "Value": "3", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 39, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 669089815, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/281475645800471/,{ "Label": "Protocol Version", "Value": "4.38", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 39, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475645800471, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/562950622511127/,{ "Label": "Application Version", "Value": "2.00", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 39, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950622511127, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "MaxAssociations": 1, "Members": [ "1.0" ], "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} +OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index e6c3757ec55..7da0557c9eb 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -260,18 +260,49 @@ def test_time_period(): """Test time_period validation.""" schema = vol.Schema(cv.time_period) - options = (None, "", "hello:world", "12:", "12:34:56:78", {}, {"wrong_key": -10}) + options = ( + None, + "", + "hello:world", + "12:", + "12:34:56:78", + {}, + {"wrong_key": -10}, + "12.5:30", + "12:30.5", + "12.5:30:30", + "12:30.5:30", + ) for value in options: with pytest.raises(vol.MultipleInvalid): schema(value) - options = ("8:20", "23:59", "-8:20", "-23:59:59", "-48:00", {"minutes": 5}, 1, "5") - for value in options: - schema(value) - - assert timedelta(seconds=180) == schema("180") - assert timedelta(hours=23, minutes=59) == schema("23:59") - assert -1 * timedelta(hours=1, minutes=15) == schema("-1:15") + options = ( + ("8:20", timedelta(hours=8, minutes=20)), + ("23:59", timedelta(hours=23, minutes=59)), + ("-8:20", -1 * timedelta(hours=8, minutes=20)), + ("-1:15", -1 * timedelta(hours=1, minutes=15)), + ("-23:59:59", -1 * timedelta(hours=23, minutes=59, seconds=59)), + ("-48:00", -1 * timedelta(days=2)), + ({"minutes": 5}, timedelta(minutes=5)), + (1, timedelta(seconds=1)), + ("5", timedelta(seconds=5)), + ("180", timedelta(seconds=180)), + ("00:08:20.5", timedelta(minutes=8, seconds=20, milliseconds=500)), + ("00:23:59.999", timedelta(minutes=23, seconds=59, milliseconds=999)), + ("-00:08:20.5", -1 * timedelta(minutes=8, seconds=20, milliseconds=500)), + ( + "-12:59:59.999", + -1 * timedelta(hours=12, minutes=59, seconds=59, milliseconds=999), + ), + ({"milliseconds": 1.5}, timedelta(milliseconds=1, microseconds=500)), + ({"seconds": "1.5"}, timedelta(seconds=1, milliseconds=500)), + ({"minutes": "1.5"}, timedelta(minutes=1, seconds=30)), + ({"hours": -1.5}, -1 * timedelta(hours=1, minutes=30)), + ({"days": "-1.5"}, -1 * timedelta(days=1, hours=12)), + ) + for value, result in options: + assert schema(value) == result def test_remove_falsy(): diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 82fadc35dd2..181a012807a 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -562,6 +562,21 @@ async def test_update(registry): assert updated_entry.identifiers == new_identifiers assert updated_entry.via_device_id == "98765B" + assert registry.async_get_device({("hue", "456")}, {}) is None + assert registry.async_get_device({("bla", "123")}, {}) is None + + assert registry.async_get_device({("hue", "654")}, {}) == updated_entry + assert registry.async_get_device({("bla", "321")}, {}) == updated_entry + + assert ( + registry.async_get_device( + {}, {(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} + ) + == updated_entry + ) + + assert registry.async_get(updated_entry.id) is not None + async def test_update_remove_config_entries(hass, registry, update_events): """Make sure we do not get duplicate entries.""" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index ddecd1988ed..6d03b087151 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -188,8 +188,8 @@ async def test_platform_warn_slow_setup(hass): assert mock_call.called # mock_calls[0] is the warning message for component setup - # mock_calls[6] is the warning message for platform setup - timeout, logger_method = mock_call.mock_calls[6][1][:2] + # mock_calls[4] is the warning message for platform setup + timeout, logger_method = mock_call.mock_calls[4][1][:2] assert timeout == entity_platform.SLOW_SETUP_WARNING assert logger_method == _LOGGER.warning @@ -375,8 +375,9 @@ async def test_async_remove_with_platform(hass): assert len(hass.states.async_entity_ids()) == 0 -async def test_not_adding_duplicate_entities_with_unique_id(hass): +async def test_not_adding_duplicate_entities_with_unique_id(hass, caplog): """Test for not adding duplicate entities.""" + caplog.set_level(logging.ERROR) component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_add_entities( @@ -384,9 +385,20 @@ async def test_not_adding_duplicate_entities_with_unique_id(hass): ) assert len(hass.states.async_entity_ids()) == 1 + assert not caplog.text ent2 = MockEntity(name="test2", unique_id="not_very_unique") await component.async_add_entities([ent2]) + assert "test1" in caplog.text + assert DOMAIN in caplog.text + + ent3 = MockEntity( + name="test2", entity_id="test_domain.test3", unique_id="not_very_unique" + ) + await component.async_add_entities([ent3]) + assert "test1" in caplog.text + assert "test3" in caplog.text + assert DOMAIN in caplog.text assert ent2.hass is None assert ent2.platform is None @@ -919,3 +931,41 @@ async def test_invalid_entity_id(hass): await platform.async_add_entities([entity]) assert entity.hass is None assert entity.platform is None + + +class MockBlockingEntity(MockEntity): + """Class to mock an entity that will block adding entities.""" + + async def async_added_to_hass(self): + """Block for a long time.""" + await asyncio.sleep(1000) + + +async def test_setup_entry_with_entities_that_block_forever(hass, caplog): + """Test we cancel adding entities when we reach the timeout.""" + registry = mock_registry(hass) + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([MockBlockingEntity(name="test1", unique_id="unique")]) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + mock_entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + with patch.object(entity_platform, "SLOW_ADD_ENTITY_MAX_WAIT", 0.01), patch.object( + entity_platform, "SLOW_ADD_MIN_TIMEOUT", 0.01 + ): + assert await mock_entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + full_name = f"{mock_entity_platform.domain}.{config_entry.domain}" + assert full_name in hass.config.components + assert len(hass.states.async_entity_ids()) == 0 + assert len(registry.entities) == 1 + assert "Timed out adding entities" in caplog.text + assert "test_domain.test1" in caplog.text + assert "test_domain" in caplog.text + assert "test" in caplog.text diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 285f43b6d4d..97d8af7d0ee 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -428,6 +428,8 @@ async def test_update_entity_unique_id(registry): entry = registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) + assert registry.async_get_entity_id("light", "hue", "5678") == entry.entity_id + new_unique_id = "1234" with patch.object(registry, "async_schedule_save") as mock_schedule_save: updated_entry = registry.async_update_entity( @@ -437,6 +439,9 @@ async def test_update_entity_unique_id(registry): assert updated_entry.unique_id == new_unique_id assert mock_schedule_save.call_count == 1 + assert registry.async_get_entity_id("light", "hue", "5678") is None + assert registry.async_get_entity_id("light", "hue", "1234") == entry.entity_id + async def test_update_entity_unique_id_conflict(registry): """Test migration raises when unique_id already in use.""" @@ -452,6 +457,8 @@ async def test_update_entity_unique_id_conflict(registry): ) as mock_schedule_save, pytest.raises(ValueError): registry.async_update_entity(entry.entity_id, new_unique_id=entry2.unique_id) assert mock_schedule_save.call_count == 0 + assert registry.async_get_entity_id("light", "hue", "5678") == entry.entity_id + assert registry.async_get_entity_id("light", "hue", "1234") == entry2.entity_id async def test_update_entity(registry): @@ -473,6 +480,10 @@ async def test_update_entity(registry): assert getattr(updated_entry, attr_name) == new_value assert getattr(updated_entry, attr_name) != getattr(entry, attr_name) + assert ( + registry.async_get_entity_id("light", "hue", "5678") + == updated_entry.entity_id + ) entry = updated_entry diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index b0034ebaaa6..aa0a69d1d67 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -1,5 +1,6 @@ """Test event helpers.""" # pylint: disable=protected-access +import asyncio from datetime import datetime, timedelta from astral import Astral @@ -9,6 +10,7 @@ from homeassistant.components import sun from homeassistant.const import MATCH_ALL import homeassistant.core as ha from homeassistant.core import callback +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.event import ( async_call_later, async_track_point_in_time, @@ -22,6 +24,7 @@ from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, async_track_utc_time_change, + track_point_in_utc_time, ) from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component @@ -47,7 +50,7 @@ async def test_track_point_in_time(hass): runs = [] async_track_point_in_utc_time( - hass, callback(lambda x: runs.append(1)), birthday_paulus + hass, callback(lambda x: runs.append(x)), birthday_paulus ) async_fire_time_changed(hass, before_birthday) @@ -64,7 +67,7 @@ async def test_track_point_in_time(hass): assert len(runs) == 1 async_track_point_in_utc_time( - hass, callback(lambda x: runs.append(1)), birthday_paulus + hass, callback(lambda x: runs.append(x)), birthday_paulus ) async_fire_time_changed(hass, after_birthday) @@ -72,7 +75,7 @@ async def test_track_point_in_time(hass): assert len(runs) == 2 unsub = async_track_point_in_time( - hass, callback(lambda x: runs.append(1)), birthday_paulus + hass, callback(lambda x: runs.append(x)), birthday_paulus ) unsub() @@ -108,7 +111,9 @@ async def test_track_state_change_from_to_state_match(hass): hass, "light.Bowl", from_and_to_state_callback, "on", "off" ) async_track_state_change(hass, "light.Bowl", only_from_state_callback, "on", None) - async_track_state_change(hass, "light.Bowl", only_to_state_callback, None, "off") + async_track_state_change( + hass, "light.Bowl", only_to_state_callback, None, ["off", "standby"] + ) async_track_state_change( hass, "light.Bowl", match_all_callback, MATCH_ALL, MATCH_ALL ) @@ -429,7 +434,7 @@ async def test_track_same_state_simple_trigger(hass): hass, period, callback_run_callback, - lambda _, _2, to_s: to_s.state == "on", + callback(lambda _, _2, to_s: to_s.state == "on"), entity_ids="light.Bowl", ) @@ -437,7 +442,10 @@ async def test_track_same_state_simple_trigger(hass): coroutine_runs.append(1) async_track_same_state( - hass, period, coroutine_run_callback, lambda _, _2, to_s: to_s.state == "on" + hass, + period, + coroutine_run_callback, + callback(lambda _, _2, to_s: to_s.state == "on"), ) # Adding state to state machine @@ -469,7 +477,7 @@ async def test_track_same_state_simple_no_trigger(hass): hass, period, callback_run_callback, - lambda _, _2, to_s: to_s.state == "on", + callback(lambda _, _2, to_s: to_s.state == "on"), entity_ids="light.Bowl", ) @@ -534,7 +542,7 @@ async def test_track_time_interval(hass): utc_now = dt_util.utcnow() unsub = async_track_time_interval( - hass, lambda x: specific_runs.append(1), timedelta(seconds=10) + hass, callback(lambda x: specific_runs.append(x)), timedelta(seconds=10) ) async_fire_time_changed(hass, utc_now + timedelta(seconds=5)) @@ -585,12 +593,14 @@ async def test_track_sunrise(hass, legacy_patchable_time): # Track sunrise runs = [] with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - unsub = async_track_sunrise(hass, lambda: runs.append(1)) + unsub = async_track_sunrise(hass, callback(lambda: runs.append(1))) offset_runs = [] offset = timedelta(minutes=30) with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - unsub2 = async_track_sunrise(hass, lambda: offset_runs.append(1), offset) + unsub2 = async_track_sunrise( + hass, callback(lambda: offset_runs.append(1)), offset + ) # run tests async_fire_time_changed(hass, next_rising - offset) @@ -643,7 +653,7 @@ async def test_track_sunrise_update_location(hass, legacy_patchable_time): # Track sunrise runs = [] with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - async_track_sunrise(hass, lambda: runs.append(1)) + async_track_sunrise(hass, callback(lambda: runs.append(1))) # Mimic sunrise async_fire_time_changed(hass, next_rising) @@ -706,12 +716,14 @@ async def test_track_sunset(hass, legacy_patchable_time): # Track sunset runs = [] with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - unsub = async_track_sunset(hass, lambda: runs.append(1)) + unsub = async_track_sunset(hass, callback(lambda: runs.append(1))) offset_runs = [] offset = timedelta(minutes=30) with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - unsub2 = async_track_sunset(hass, lambda: offset_runs.append(1), offset) + unsub2 = async_track_sunset( + hass, callback(lambda: offset_runs.append(1)), offset + ) # Run tests async_fire_time_changed(hass, next_setting - offset) @@ -743,22 +755,39 @@ async def test_async_track_time_change(hass): wildcard_runs = [] specific_runs = [] - unsub = async_track_time_change(hass, lambda x: wildcard_runs.append(1)) - unsub_utc = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), second=[0, 30] + now = dt_util.utcnow() + + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 0)) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_time_change( + hass, callback(lambda x: wildcard_runs.append(x)) + ) + unsub_utc = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 15)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 15, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 2 - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 30)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 @@ -766,7 +795,9 @@ async def test_async_track_time_change(hass): unsub() unsub_utc() - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 30)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 @@ -776,25 +807,42 @@ async def test_periodic_task_minute(hass): """Test periodic tasks per minute.""" specific_runs = [] - unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), minute="/5", second=0 + now = dt_util.utcnow() + + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 0)) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 3, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 3, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 5, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 5, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 5, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 5, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -803,33 +851,58 @@ async def test_periodic_task_hour(hass): """Test periodic tasks per hour.""" specific_runs = [] - unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 + now = dt_util.utcnow() + + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 23, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 25, 0, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(2014, 5, 25, 1, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 1, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 3 unsub() - async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 2, 0, 0, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 3 @@ -838,12 +911,16 @@ async def test_periodic_task_wrong_input(hass): """Test periodic tasks with wrong input.""" specific_runs = [] + now = dt_util.utcnow() + with pytest.raises(ValueError): async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), hour="/two" + hass, callback(lambda x: specific_runs.append(x)), hour="/two" ) - async_fire_time_changed(hass, datetime(2014, 5, 2, 0, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 2, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 0 @@ -852,33 +929,62 @@ async def test_periodic_task_clock_rollback(hass): """Test periodic tasks with the time rolling backwards.""" specific_runs = [] - unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 + now = dt_util.utcnow() + + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 23, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + async_fire_time_changed( + hass, + datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC), + fire_all=True, + ) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(2014, 5, 24, 0, 0, 0)) + async_fire_time_changed( + hass, + datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC), + fire_all=True, + ) await hass.async_block_till_done() assert len(specific_runs) == 3 - async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 4 unsub() - async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 4 @@ -887,19 +993,38 @@ async def test_periodic_task_duplicate_time(hass): """Test periodic tasks not triggering on duplicate time.""" specific_runs = [] - unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 + now = dt_util.utcnow() + + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 25, 0, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -912,23 +1037,43 @@ async def test_periodic_task_entering_dst(hass): dt_util.set_default_time_zone(timezone) specific_runs = [] - unsub = async_track_time_change( - hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 + now = dt_util.utcnow() + time_that_will_not_match_right_away = timezone.localize( + datetime(now.year + 1, 3, 25, 2, 31, 0) ) - async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 25, 1, 50, 0))) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour=2, + minute=30, + second=0, + ) + + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 1, 3, 25, 1, 50, 0, 999999)) + ) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 25, 3, 50, 0))) + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 1, 3, 25, 3, 50, 0, 999999)) + ) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 26, 1, 50, 0))) + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 1, 3, 26, 1, 50, 0, 999999)) + ) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 26, 2, 50, 0))) + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 1, 3, 26, 2, 50, 0, 999999)) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 @@ -941,30 +1086,64 @@ async def test_periodic_task_leaving_dst(hass): dt_util.set_default_time_zone(timezone) specific_runs = [] - unsub = async_track_time_change( - hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 + now = dt_util.utcnow() + + time_that_will_not_match_right_away = timezone.localize( + datetime(now.year + 1, 10, 28, 2, 28, 0), is_dst=True ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour=2, + minute=30, + second=0, + ) + async_fire_time_changed( - hass, timezone.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=False) + hass, + timezone.localize( + datetime(now.year + 1, 10, 28, 2, 5, 0, 999999), is_dst=False + ), ) await hass.async_block_till_done() assert len(specific_runs) == 0 async_fire_time_changed( - hass, timezone.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=False) + hass, + timezone.localize( + datetime(now.year + 1, 10, 28, 2, 55, 0, 999999), is_dst=False + ), ) await hass.async_block_till_done() assert len(specific_runs) == 1 async_fire_time_changed( - hass, timezone.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=True) + hass, + timezone.localize( + datetime(now.year + 2, 10, 28, 2, 45, 0, 999999), is_dst=True + ), ) await hass.async_block_till_done() - assert len(specific_runs) == 1 + assert len(specific_runs) == 2 async_fire_time_changed( - hass, timezone.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True) + hass, + timezone.localize( + datetime(now.year + 2, 10, 28, 2, 55, 0, 999999), is_dst=True + ), + ) + await hass.async_block_till_done() + assert len(specific_runs) == 2 + + async_fire_time_changed( + hass, + timezone.localize( + datetime(now.year + 2, 10, 28, 2, 55, 0, 999999), is_dst=True + ), ) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -1112,3 +1291,168 @@ async def test_track_state_change_event_chain_single_entity(hass): assert len(chained_tracker_called) == 1 assert len(tracker_unsub) == 1 assert len(chained_tracker_unsub) == 2 + + +async def test_track_point_in_utc_time_cancel(hass): + """Test cancel of async track point in time.""" + + times = [] + + @ha.callback + def run_callback(utc_time): + nonlocal times + times.append(utc_time) + + def _setup_listeners(): + """Ensure we test the non-async version.""" + utc_now = dt_util.utcnow() + + with pytest.raises(TypeError): + track_point_in_utc_time("nothass", run_callback, utc_now) + + unsub1 = hass.helpers.event.track_point_in_utc_time( + run_callback, utc_now + timedelta(seconds=0.1) + ) + hass.helpers.event.track_point_in_utc_time( + run_callback, utc_now + timedelta(seconds=0.1) + ) + + unsub1() + + await hass.async_add_executor_job(_setup_listeners) + + await asyncio.sleep(0.2) + + assert len(times) == 1 + assert times[0].tzinfo == dt_util.UTC + + +async def test_async_track_point_in_time_cancel(hass): + """Test cancel of async track point in time.""" + + times = [] + hst_tz = dt_util.get_time_zone("US/Hawaii") + dt_util.set_default_time_zone(hst_tz) + + @ha.callback + def run_callback(local_time): + nonlocal times + times.append(local_time) + + utc_now = dt_util.utcnow() + hst_now = utc_now.astimezone(hst_tz) + + unsub1 = hass.helpers.event.async_track_point_in_time( + run_callback, hst_now + timedelta(seconds=0.1) + ) + hass.helpers.event.async_track_point_in_time( + run_callback, hst_now + timedelta(seconds=0.1) + ) + + unsub1() + + await asyncio.sleep(0.2) + + assert len(times) == 1 + assert times[0].tzinfo.zone == "US/Hawaii" + + +async def test_async_track_entity_registry_updated_event(hass): + """Test tracking entity registry updates for an entity_id.""" + + entity_id = "switch.puppy_feeder" + new_entity_id = "switch.dog_feeder" + untracked_entity_id = "switch.kitty_feeder" + + hass.states.async_set(entity_id, "on") + await hass.async_block_till_done() + event_data = [] + + @ha.callback + def run_callback(event): + event_data.append(event.data) + + unsub1 = hass.helpers.event.async_track_entity_registry_updated_event( + entity_id, run_callback + ) + unsub2 = hass.helpers.event.async_track_entity_registry_updated_event( + new_entity_id, run_callback + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + {"action": "create", "entity_id": untracked_entity_id}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": new_entity_id, + "old_entity_id": entity_id, + "changes": {}, + }, + ) + await hass.async_block_till_done() + + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "remove", "entity_id": new_entity_id} + ) + await hass.async_block_till_done() + + unsub1() + unsub2() + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": new_entity_id} + ) + await hass.async_block_till_done() + + assert event_data[0] == {"action": "create", "entity_id": "switch.puppy_feeder"} + assert event_data[1] == { + "action": "update", + "changes": {}, + "entity_id": "switch.dog_feeder", + "old_entity_id": "switch.puppy_feeder", + } + assert event_data[2] == {"action": "remove", "entity_id": "switch.dog_feeder"} + + +async def test_async_track_entity_registry_updated_event_with_a_callback_that_throws( + hass, +): + """Test tracking entity registry updates for an entity_id when one callback throws.""" + + entity_id = "switch.puppy_feeder" + + hass.states.async_set(entity_id, "on") + await hass.async_block_till_done() + event_data = [] + + @ha.callback + def run_callback(event): + event_data.append(event.data) + + @ha.callback + def failing_callback(event): + raise ValueError + + unsub1 = hass.helpers.event.async_track_entity_registry_updated_event( + entity_id, failing_callback + ) + unsub2 = hass.helpers.event.async_track_entity_registry_updated_event( + entity_id, run_callback + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} + ) + await hass.async_block_till_done() + unsub1() + unsub2() + + assert event_data[0] == {"action": "create", "entity_id": "switch.puppy_feeder"} diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 33dec87ccd8..fbfd06aa930 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1290,23 +1290,46 @@ async def test_script_mode_queued(hass): sequence = cv.SCRIPT_SCHEMA( [ {"event": event, "event_data": {"value": 1}}, - {"wait_template": "{{ states.switch.test.state == 'off' }}"}, + { + "wait_template": "{{ states.switch.test.state == 'off' }}", + "alias": "wait_1", + }, {"event": event, "event_data": {"value": 2}}, - {"wait_template": "{{ states.switch.test.state == 'on' }}"}, + { + "wait_template": "{{ states.switch.test.state == 'on' }}", + "alias": "wait_2", + }, ] ) logger = logging.getLogger("TEST") script_obj = script.Script( hass, sequence, script_mode="queued", max_runs=2, logger=logger ) - wait_started_flag = async_watch_for_action(script_obj, "wait") + + watch_messages = [] + + @callback + def check_action(): + for message, flag in watch_messages: + if script_obj.last_action and message in script_obj.last_action: + flag.set() + + script_obj.change_listener = check_action + wait_started_flag_1 = asyncio.Event() + watch_messages.append(("wait_1", wait_started_flag_1)) + wait_started_flag_2 = asyncio.Event() + watch_messages.append(("wait_2", wait_started_flag_2)) try: + assert not script_obj.is_running + assert script_obj.runs == 0 + hass.states.async_set("switch.test", "on") hass.async_create_task(script_obj.async_run()) - await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.wait_for(wait_started_flag_1.wait(), 1) assert script_obj.is_running + assert script_obj.runs == 1 assert len(events) == 1 assert events[0].data["value"] == 1 @@ -1314,25 +1337,26 @@ async def test_script_mode_queued(hass): # This second run should not start until the first run has finished. hass.async_create_task(script_obj.async_run()) - await asyncio.sleep(0) + assert script_obj.is_running + assert script_obj.runs == 2 assert len(events) == 1 - wait_started_flag.clear() hass.states.async_set("switch.test", "off") - await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.wait_for(wait_started_flag_2.wait(), 1) assert script_obj.is_running + assert script_obj.runs == 2 assert len(events) == 2 assert events[1].data["value"] == 2 - wait_started_flag.clear() + wait_started_flag_1.clear() hass.states.async_set("switch.test", "on") - await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.wait_for(wait_started_flag_1.wait(), 1) - await asyncio.sleep(0) assert script_obj.is_running + assert script_obj.runs == 1 assert len(events) == 3 assert events[2].data["value"] == 1 except (AssertionError, asyncio.TimeoutError): @@ -1345,10 +1369,52 @@ async def test_script_mode_queued(hass): await hass.async_block_till_done() assert not script_obj.is_running + assert script_obj.runs == 0 assert len(events) == 4 assert events[3].data["value"] == 2 +async def test_script_mode_queued_cancel(hass): + """Test canceling with a queued run.""" + script_obj = script.Script( + hass, + cv.SCRIPT_SCHEMA({"wait_template": "{{ false }}"}), + "test", + script_mode="queued", + max_runs=2, + ) + wait_started_flag = async_watch_for_action(script_obj, "wait") + + try: + assert not script_obj.is_running + assert script_obj.runs == 0 + + task1 = hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) + task2 = hass.async_create_task(script_obj.async_run()) + await asyncio.sleep(0) + + assert script_obj.is_running + assert script_obj.runs == 2 + + with pytest.raises(asyncio.CancelledError): + task2.cancel() + await task2 + + assert script_obj.is_running + assert script_obj.runs == 1 + + with pytest.raises(asyncio.CancelledError): + task1.cancel() + await task1 + + assert not script_obj.is_running + assert script_obj.runs == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + + async def test_script_logging(hass, caplog): """Test script logging.""" script_obj = script.Script(hass, [], "Script with % Name") @@ -1414,3 +1480,22 @@ async def test_shutdown_after(hass, caplog): "Stopping scripts running too long after shutdown: test script" in caplog.text ) + + +async def test_update_logger(hass, caplog): + """Test updating logger.""" + sequence = cv.SCRIPT_SCHEMA({"event": "test_event"}) + script_obj = script.Script(hass, sequence) + + await script_obj.async_run() + await hass.async_block_till_done() + + assert script.__name__ in caplog.text + + log_name = "testing.123" + script_obj.update_logger(logging.getLogger(log_name)) + + await script_obj.async_run() + await hass.async_block_till_done() + + assert log_name in caplog.text diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f755e4e1084..89486129760 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1885,3 +1885,30 @@ def test_urlencode(hass): hass, ) assert tpl.async_render() == "the%20quick%20brown%20fox%20%3D%20true" + + +async def test_cache_garbage_collection(): + """Test caching a template.""" + template_string = ( + "{% set dict = {'foo': 'x&y', 'bar': 42} %} {{ dict | urlencode }}" + ) + tpl = template.Template((template_string),) + tpl.ensure_valid() + assert template._NO_HASS_ENV.template_cache.get( + template_string + ) # pylint: disable=protected-access + + tpl2 = template.Template((template_string),) + tpl2.ensure_valid() + assert template._NO_HASS_ENV.template_cache.get( + template_string + ) # pylint: disable=protected-access + + del tpl + assert template._NO_HASS_ENV.template_cache.get( + template_string + ) # pylint: disable=protected-access + del tpl2 + assert not template._NO_HASS_ENV.template_cache.get( + template_string + ) # pylint: disable=protected-access diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 99399fee30f..56c53f1994c 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -2,9 +2,11 @@ import asyncio from datetime import timedelta import logging +import urllib.error import aiohttp import pytest +import requests from homeassistant.helpers import update_coordinator from homeassistant.util.dt import utcnow @@ -19,12 +21,12 @@ def get_crd(hass, update_interval): """Make coordinator mocks.""" calls = 0 - async def refresh(): + async def refresh() -> int: nonlocal calls calls += 1 return calls - crd = update_coordinator.DataUpdateCoordinator( + crd = update_coordinator.DataUpdateCoordinator[int]( hass, LOGGER, name="test", @@ -111,7 +113,11 @@ async def test_request_refresh_no_auto_update(crd_without_update_interval): "err_msg", [ (asyncio.TimeoutError, "Timeout fetching test data"), + (requests.exceptions.Timeout, "Timeout fetching test data"), + (urllib.error.URLError("timed out"), "Timeout fetching test data"), (aiohttp.ClientError, "Error requesting test data"), + (requests.exceptions.RequestException, "Error requesting test data"), + (urllib.error.URLError("something"), "Error requesting test data"), (update_coordinator.UpdateFailed, "Error fetching test data"), ], ) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index c4f7d2b08c5..10034cb08af 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -113,7 +113,6 @@ def test_secrets(isfile_patch, loop): "cors_allowed_origins": ["http://google.com"], "ip_ban_enabled": True, "login_attempts_threshold": -1, - "server_host": "0.0.0.0", "server_port": 8123, "ssl_profile": "modern", } diff --git a/tests/test_core.py b/tests/test_core.py index a63f42af61b..77baa502687 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1090,9 +1090,20 @@ def test_timer_out_of_sync(mock_monotonic, loop): ): callback(target) - event_type, event_data = hass.bus.async_fire.mock_calls[1][1] - assert event_type == EVENT_TIMER_OUT_OF_SYNC - assert abs(event_data[ATTR_SECONDS] - 2.433333) < 0.001 + _, event_0_args, event_0_kwargs = hass.bus.async_fire.mock_calls[0] + event_context_0 = event_0_kwargs["context"] + + event_type_0, _ = event_0_args + assert event_type_0 == EVENT_TIME_CHANGED + + _, event_1_args, event_1_kwargs = hass.bus.async_fire.mock_calls[1] + event_type_1, event_data_1 = event_1_args + event_context_1 = event_1_kwargs["context"] + + assert event_type_1 == EVENT_TIMER_OUT_OF_SYNC + assert abs(event_data_1[ATTR_SECONDS] - 2.433333) < 0.001 + + assert event_context_0 == event_context_1 assert len(funcs) == 2 fire_time_event, _ = funcs @@ -1129,8 +1140,8 @@ async def test_start_taking_too_long(loop, caplog): caplog.set_level(logging.WARNING) try: - with patch( - "homeassistant.core.timeout", side_effect=asyncio.TimeoutError + with patch.object( + hass, "async_block_till_done", side_effect=asyncio.TimeoutError ), patch("homeassistant.core._async_create_timer") as mock_timer: await hass.async_start() @@ -1425,14 +1436,14 @@ async def test_chained_logging_hits_log_timeout(hass, caplog): async def _task_chain_1(): nonlocal created created += 1 - if created > 10: + if created > 1000: return hass.async_create_task(_task_chain_2()) async def _task_chain_2(): nonlocal created created += 1 - if created > 10: + if created > 1000: return hass.async_create_task(_task_chain_1()) diff --git a/tests/test_loader.py b/tests/test_loader.py index 20669588180..272b0453469 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -168,10 +168,36 @@ def test_integration_properties(hass): "domain": "hue", "dependencies": ["test-dep"], "requirements": ["test-req==1.0.0"], + "zeroconf": ["_hue._tcp.local."], + "homekit": {"models": ["BSB002"]}, + "ssdp": [ + { + "manufacturer": "Royal Philips Electronics", + "modelName": "Philips hue bridge 2012", + }, + { + "manufacturer": "Royal Philips Electronics", + "modelName": "Philips hue bridge 2015", + }, + {"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}, + ], }, ) assert integration.name == "Philips Hue" assert integration.domain == "hue" + assert integration.homekit == {"models": ["BSB002"]} + assert integration.zeroconf == ["_hue._tcp.local."] + assert integration.ssdp == [ + { + "manufacturer": "Royal Philips Electronics", + "modelName": "Philips hue bridge 2012", + }, + { + "manufacturer": "Royal Philips Electronics", + "modelName": "Philips hue bridge 2015", + }, + {"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}, + ] assert integration.dependencies == ["test-dep"] assert integration.requirements == ["test-req==1.0.0"] assert integration.is_built_in is True @@ -188,6 +214,9 @@ def test_integration_properties(hass): }, ) assert integration.is_built_in is False + assert integration.homekit is None + assert integration.zeroconf is None + assert integration.ssdp is None async def test_integrations_only_once(hass): @@ -217,6 +246,9 @@ def _get_test_integration(hass, name, config_flow): "config_flow": config_flow, "dependencies": [], "requirements": [], + "zeroconf": [f"_{name}._tcp.local."], + "homekit": {"models": [name]}, + "ssdp": [{"manufacturer": name, "modelName": name}], }, ) @@ -254,6 +286,51 @@ async def test_get_config_flows(hass): assert "test_1" not in flows +async def test_get_zeroconf(hass): + """Verify that custom components with zeroconf are found.""" + test_1_integration = _get_test_integration(hass, "test_1", True) + test_2_integration = _get_test_integration(hass, "test_2", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + "test_2": test_2_integration, + } + zeroconf = await loader.async_get_zeroconf(hass) + assert zeroconf["_test_1._tcp.local."] == ["test_1"] + assert zeroconf["_test_2._tcp.local."] == ["test_2"] + + +async def test_get_homekit(hass): + """Verify that custom components with homekit are found.""" + test_1_integration = _get_test_integration(hass, "test_1", True) + test_2_integration = _get_test_integration(hass, "test_2", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + "test_2": test_2_integration, + } + homekit = await loader.async_get_homekit(hass) + assert homekit["test_1"] == "test_1" + assert homekit["test_2"] == "test_2" + + +async def test_get_ssdp(hass): + """Verify that custom components with ssdp are found.""" + test_1_integration = _get_test_integration(hass, "test_1", True) + test_2_integration = _get_test_integration(hass, "test_2", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + "test_2": test_2_integration, + } + ssdp = await loader.async_get_ssdp(hass) + assert ssdp["test_1"] == [{"manufacturer": "test_1", "modelName": "test_1"}] + assert ssdp["test_2"] == [{"manufacturer": "test_2", "modelName": "test_2"}] + + async def test_get_custom_components_safe_mode(hass): """Test that we get empty custom components in safe mode.""" hass.config.safe_mode = True diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 20202f91e89..fcc2d571331 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -1,15 +1,12 @@ """Test requirements module.""" import os -from pathlib import Path import pytest from homeassistant import loader, setup from homeassistant.requirements import ( CONSTRAINT_FILE, - PROGRESS_FILE, RequirementsNotFound, - _install, async_get_integration_with_requirements, async_process_requirements, ) @@ -190,24 +187,6 @@ async def test_install_on_docker(hass): ) -async def test_progress_lock(hass): - """Test an install attempt on an existing package.""" - progress_path = Path(hass.config.path(PROGRESS_FILE)) - kwargs = {"hello": "world"} - - def assert_env(req, **passed_kwargs): - """Assert the env.""" - assert progress_path.exists() - assert req == "hello" - assert passed_kwargs == kwargs - return True - - with patch("homeassistant.util.package.install_package", side_effect=assert_env): - _install(hass, "hello", kwargs) - - assert not progress_path.exists() - - async def test_discovery_requirements_ssdp(hass): """Test that we load discovery requirements.""" hass.config.skip_pip = False diff --git a/tests/test_setup.py b/tests/test_setup.py index cb63f8fa865..abd9cecd9ac 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -488,15 +488,12 @@ async def test_component_warn_slow_setup(hass): assert result assert mock_call.called - assert len(mock_call.mock_calls) == 5 + assert len(mock_call.mock_calls) == 3 timeout, logger_method = mock_call.mock_calls[0][1][:2] assert timeout == setup.SLOW_SETUP_WARNING assert logger_method == setup._LOGGER.warning - timeout, function = mock_call.mock_calls[1][1][:2] - assert timeout == setup.SLOW_SETUP_MAX_WAIT - assert mock_call().cancel.called @@ -508,8 +505,7 @@ async def test_platform_no_warn_slow(hass): with patch.object(hass.loop, "call_later") as mock_call: result = await setup.async_setup_component(hass, "test_component1", {}) assert result - timeout, function = mock_call.mock_calls[0][1][:2] - assert timeout == setup.SLOW_SETUP_MAX_WAIT + assert len(mock_call.mock_calls) == 0 async def test_platform_error_slow_setup(hass, caplog): diff --git a/tests/util/test_process.py b/tests/util/test_process.py new file mode 100644 index 00000000000..a82df0dbb99 --- /dev/null +++ b/tests/util/test_process.py @@ -0,0 +1,26 @@ +"""Test process util.""" + +import os +import subprocess + +import pytest + +from homeassistant.util import process + + +async def test_kill_process(): + """Test killing a process.""" + sleeper = subprocess.Popen( + "sleep 1000", + shell=True, # nosec # shell by design + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + pid = sleeper.pid + + assert os.kill(pid, 0) is None + + process.kill_subprocess(sleeper) + + with pytest.raises(OSError): + os.kill(pid, 0) diff --git a/tests/util/test_timeout.py b/tests/util/test_timeout.py new file mode 100644 index 00000000000..edd8f4107a4 --- /dev/null +++ b/tests/util/test_timeout.py @@ -0,0 +1,268 @@ +"""Test Home Assistant timeout handler.""" +import asyncio +import time + +import pytest + +from homeassistant.util.timeout import TimeoutManager + + +async def test_simple_global_timeout(): + """Test a simple global timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + await asyncio.sleep(0.3) + + +async def test_simple_global_timeout_with_executor_job(hass): + """Test a simple global timeout with executor job.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + await hass.async_add_executor_job(lambda: time.sleep(0.2)) + + +async def test_simple_global_timeout_freeze(): + """Test a simple global timeout freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_inside_executor_job(hass): + """Test a simple zone timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + async with timeout.async_timeout(1.0): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_simple_global_timeout_freeze_inside_executor_job(hass): + """Test a simple global timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze(): + time.sleep(0.3) + + async with timeout.async_timeout(0.2): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job(hass): + """Test a simple global timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_different_order(hass): + """Test a simple global timeout freeze inside an executor job before timeout was set.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.4) + + async with timeout.async_timeout(0.1): + hass.async_add_executor_job(_some_sync_work) + async with timeout.async_timeout(0.2, zone_name="recorder"): + await asyncio.sleep(0.3) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_other_zone_inside_executor_job( + hass, +): + """Test a simple global timeout freeze other zone inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("not_recorder"): + time.sleep(0.3) + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + async with timeout.async_timeout(0.2, zone_name="not_recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job_second_job_outside_zone_context( + hass, +): + """Test a simple global timeout freeze inside an executor job with second job outside of zone context.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + await hass.async_add_executor_job(lambda: time.sleep(0.2)) + + +async def test_simple_global_timeout_freeze_with_executor_job(hass): + """Test a simple global timeout freeze with executor job.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await hass.async_add_executor_job(lambda: time.sleep(0.3)) + + +async def test_simple_global_timeout_freeze_reset(): + """Test a simple global timeout freeze reset.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await asyncio.sleep(0.1) + await asyncio.sleep(0.2) + + +async def test_simple_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + + +async def test_multiple_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_timeout(0.5, "test"): + await asyncio.sleep(0.3) + + +async def test_different_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_timeout(0.5, "other"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze(): + """Test a simple zone timeout freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_without_timeout(): + """Test a simple zone timeout freeze on a zone that does not have a timeout set.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_reset(): + """Test a simple zone timeout freeze reset.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.1) + await asyncio.sleep(0.2, "test") + + +async def test_mix_zone_timeout_freeze_and_global_freeze(): + """Test a mix zone timeout freeze and global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_mix_global_and_zone_timeout_freeze_(): + """Test a mix zone timeout freeze and global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze(): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout_freeze(): + """Test a mix zone timeout global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout(): + """Test a mix zone timeout global.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1): + try: + async with timeout.async_timeout(0.2, "test"): + await asyncio.sleep(0.4) + except asyncio.TimeoutError: + pass + + +async def test_mix_zone_timeout_trigger_global(): + """Test a mix zone timeout global with trigger it.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + try: + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + except asyncio.TimeoutError: + pass + + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout_trigger_global_cool_down(): + """Test a mix zone timeout global with trigger it with cool_down.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1, cool_down=0.3): + try: + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + except asyncio.TimeoutError: + pass + + await asyncio.sleep(0.2)