diff --git a/.coveragerc b/.coveragerc index 11b5b91ae2228..fa743f6649deb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -50,6 +50,7 @@ omit = homeassistant/components/arwn/sensor.py homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_mbox/* + homeassistant/components/atome/* homeassistant/components/asuswrt/device_tracker.py homeassistant/components/august/* homeassistant/components/aurora_abb_powerone/sensor.py @@ -241,7 +242,6 @@ omit = homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_travel_time/sensor.py - homeassistant/components/googlehome/* homeassistant/components/gpmdp/media_player.py homeassistant/components/gpsd/sensor.py homeassistant/components/greeneye_monitor/* @@ -308,6 +308,7 @@ omit = homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/* homeassistant/components/kankun/switch.py + homeassistant/components/keba/* homeassistant/components/keenetic_ndms2/device_tracker.py homeassistant/components/keyboard/* homeassistant/components/keyboard_remote/* @@ -377,6 +378,7 @@ omit = homeassistant/components/miflora/sensor.py homeassistant/components/mikrotik/* homeassistant/components/mill/climate.py + homeassistant/components/minio/* homeassistant/components/mitemp_bt/sensor.py homeassistant/components/mjpeg/camera.py homeassistant/components/mobile_app/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 595506c8ccc71..22bd4384b23e5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,11 +2,12 @@ { "name": "Home Assistant Dev", "context": "..", - "dockerFile": "Dockerfile", + "dockerFile": "../Dockerfile.dev", "postCreateCommand": "pip3 install -e .", "appPort": 8123, "runArgs": [ - "-e", "GIT_EDITOR='code --wait'" + "-e", + "GIT_EDITOR=\"code --wait\"" ], "extensions": [ "ms-python.python", @@ -31,4 +32,4 @@ "!include_dir_merge_named scalar" ] } -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 65b325a0a4bdf..5389954ca5957 100644 --- a/.gitignore +++ b/.gitignore @@ -59,9 +59,11 @@ pip-log.txt # Unit test / coverage reports .coverage .tox +coverage.xml nosetests.xml htmlcov/ test-reports/ +test-results.xml # Translations *.mo @@ -122,3 +124,6 @@ desktop.ini # monkeytype monkeytype.sqlite3 + +# This is left behind by Azure Restore Cache +tmp_cache diff --git a/.travis.yml b/.travis.yml index f54f4027de4ea..3447571a3e857 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,14 +16,18 @@ addons: matrix: fast_finish: true include: - - python: "3.6" + - python: "3.6.0" env: TOXENV=lint - - python: "3.6" + dist: trusty + - python: "3.6.0" env: TOXENV=pylint - - python: "3.6" + dist: trusty + - python: "3.6.0" env: TOXENV=typing - - python: "3.6" + dist: trusty + - python: "3.6.0" env: TOXENV=py36 + dist: trusty - python: "3.7" env: TOXENV=py37 diff --git a/CODEOWNERS b/CODEOWNERS index 9c2fa006a1348..1c215dd22784a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -9,10 +9,6 @@ homeassistant/*.py @home-assistant/core homeassistant/helpers/* @home-assistant/core homeassistant/util/* @home-assistant/core -# Virtualization -Dockerfile @home-assistant/docker -virtualization/Docker/* @home-assistant/docker - # Other code homeassistant/scripts/check_config.py @kellerza @@ -32,6 +28,7 @@ homeassistant/components/arcam_fmj/* @elupus homeassistant/components/arduino/* @fabaff homeassistant/components/arest/* @fabaff homeassistant/components/asuswrt/* @kennedyshead +homeassistant/components/atome/* @baqs homeassistant/components/aurora_abb_powerone/* @davet2001 homeassistant/components/auth/* @home-assistant/core homeassistant/components/automatic/* @armills @@ -44,6 +41,7 @@ homeassistant/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/bitcoin/* @fabaff homeassistant/components/bizkaibus/* @UgaitzEtxebarria homeassistant/components/blink/* @fronzbot +homeassistant/components/bmw_connected_drive/* @gerard33 homeassistant/components/braviatv/* @robbiet480 homeassistant/components/broadlink/* @danielhiversen homeassistant/components/brunt/* @eavanvalkenburg @@ -108,7 +106,6 @@ homeassistant/components/gntp/* @robbiet480 homeassistant/components/google_cloud/* @lufton homeassistant/components/google_translate/* @awarecan homeassistant/components/google_travel_time/* @robbiet480 -homeassistant/components/googlehome/* @ludeeus homeassistant/components/gpsd/* @fabaff homeassistant/components/group/* @home-assistant/core homeassistant/components/gtfs/* @robbiet480 @@ -123,6 +120,7 @@ homeassistant/components/hive/* @Rendili @KJonline homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit_controller/* @Jc2k homeassistant/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/homematicip_cloud/* @SukramJ homeassistant/components/honeywell/* @zxdavb homeassistant/components/html5/* @robbiet480 homeassistant/components/http/* @home-assistant/core @@ -143,6 +141,7 @@ homeassistant/components/ipma/* @dgomes homeassistant/components/iqvia/* @bachya homeassistant/components/irish_rail_transport/* @ttroy50 homeassistant/components/jewish_calendar/* @tsvi +homeassistant/components/keba/* @dannerph homeassistant/components/knx/* @Julius2342 homeassistant/components/kodi/* @armills homeassistant/components/konnected/* @heythisisnate @@ -172,6 +171,7 @@ homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel homeassistant/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff +homeassistant/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 homeassistant/components/monoprice/* @etsinko homeassistant/components/moon/* @fabaff @@ -182,6 +182,7 @@ homeassistant/components/nello/* @pschmitt homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @awarecan homeassistant/components/netdata/* @fabaff +homeassistant/components/netgear_lte/* @amelchio homeassistant/components/nextbus/* @vividboarder homeassistant/components/nissan_leaf/* @filcole homeassistant/components/nmbs/* @thibmaek @@ -190,6 +191,7 @@ homeassistant/components/notify/* @home-assistant/core homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nuki/* @pschmitt +homeassistant/components/nws/* @MatthewFlamm homeassistant/components/ohmconnect/* @robbiet480 homeassistant/components/onboarding/* @home-assistant/core homeassistant/components/opentherm_gw/* @mvn23 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ebd802374ebda..0000000000000 --- a/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -# Notice: -# When updating this file, please also update virtualization/Docker/Dockerfile.dev -# This way, the development image and the production image are kept in sync. - -FROM python:3.7-buster -LABEL maintainer="Paulus Schoutsen " - -# Uncomment any of the following lines to disable the installation. -#ENV INSTALL_TELLSTICK no -#ENV INSTALL_OPENALPR no -#ENV INSTALL_FFMPEG no -#ENV INSTALL_LIBCEC no -#ENV INSTALL_SSOCR no -#ENV INSTALL_DLIB no -#ENV INSTALL_IPERF3 no -#ENV INSTALL_LOCALES no - -VOLUME /config - -WORKDIR /usr/src/app - -# Copy build scripts -COPY virtualization/Docker/ virtualization/Docker/ -RUN virtualization/Docker/setup_docker_prereqs - -# Install hass component dependencies -COPY requirements_all.txt requirements_all.txt -RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 cchardet cython tensorflow - -# Copy source -COPY . . - -EXPOSE 8123 -EXPOSE 8300 -EXPOSE 51827 - -CMD [ "python", "-m", "homeassistant", "--config", "/config" ] diff --git a/.devcontainer/Dockerfile b/Dockerfile.dev similarity index 97% rename from .devcontainer/Dockerfile rename to Dockerfile.dev index 3bfc7e94148dc..00f5576bdbb0f 100644 --- a/.devcontainer/Dockerfile +++ b/Dockerfile.dev @@ -16,6 +16,7 @@ RUN apt-get update \ WORKDIR /usr/src +# Setup hass-release RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ && cd hass-release \ && pip3 install -e . diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 5297fd802318d..0ee272f900daa 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -114,6 +114,7 @@ stages: - script: | . venv/bin/activate pytest --timeout=9 --durations=10 --junitxml=test-results.xml -qq -o console_output_style=count -p no:sugar tests + script/check_dirty displayName: 'Run pytest for python $(python.container)' condition: and(succeeded(), ne(variables['python.container'], variables['PythonMain'])) - script: | @@ -122,6 +123,7 @@ stages: . venv/bin/activate pytest --timeout=9 --durations=10 --junitxml=test-results.xml --cov --cov-report=xml -qq -o console_output_style=count -p no:sugar tests codecov --token $(codecovToken) + script/check_dirty displayName: 'Run pytest for python $(python.container) / coverage' condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) - task: PublishTestResults@2 diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 13a031fda15d7..2e537fbb77456 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -7,7 +7,7 @@ trigger: pr: none variables: - name: versionBuilder - value: '5.2' + value: '6.1' - group: docker - group: github - group: twine @@ -60,7 +60,7 @@ stages: - script: | export TWINE_USERNAME="$(twineUser)" export TWINE_PASSWORD="$(twinePassword)" - + twine upload dist/* --skip-existing displayName: 'Upload pypi' - job: 'ReleaseDocker' @@ -150,3 +150,73 @@ stages: git commit -am "Bump Home Assistant $version" git push displayName: 'Update version files' + - job: 'ReleaseDocker' + pool: + vmImage: 'ubuntu-latest' + steps: + - script: | + mkdir -p ~/.docker + echo '{ "experimental": "enabled" }' > .docker/config.json + + sudo docker login -u $(dockerUser) -p $(dockerPassword) + displayName: 'Enable manifest / Docker login' + - script: | + set -e + + function create_manifest() { + local tag_l=$1 + local tag_r=$2 + + sudo docker --config .docker manifest create homeassistant/home-assistant:${tag_l} \ + homeassistant/amd64-homeassistant:${tag_r} \ + homeassistant/i386-homeassistant:${tag_r} \ + homeassistant/armhf-homeassistant:${tag_r} \ + homeassistant/armv7-homeassistant:${tag_r} \ + homeassistant/aarch64-homeassistant:${tag_r} + + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/amd64-homeassistant:${tag_r} \ + --os linux --arch amd64 + + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/i386-homeassistant:${tag_r} \ + --os linux --arch 386 + + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/armhf-homeassistant:${tag_r} \ + --os linux --arch arm --variant=v6 + + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/armv7-homeassistant:${tag_r} \ + --os linux --arch arm --variant=v7 + + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/aarch64-homeassistant:${tag_r} \ + --os linux --arch arm64 --variant=v8 + + sudo docker --config .docker manifest push --purge homeassistant/home-assistant:${tag_l} + } + + sudo docker pull homeassistant/amd64-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/i386-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/armhf-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/armv7-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/aarch64-homeassistant:$(Build.SourceBranchName) + + # Create version tag + create_manifest "$(Build.SourceBranchName)" "$(Build.SourceBranchName)" + + # Create general tags + if [[ "$version" =~ d ]]; then + create_manifest "dev" "$(Build.SourceBranchName)" + elif [[ "$version" =~ b ]]; then + create_manifest "beta" "$(Build.SourceBranchName)" + create_manifest "rc" "$(Build.SourceBranchName)" + else + create_manifest "stable" "$(Build.SourceBranchName)" + create_manifest "latest" "$(Build.SourceBranchName)" + create_manifest "beta" "$(Build.SourceBranchName)" + create_manifest "rc" "$(Build.SourceBranchName)" + fi + + displayName: 'Create Meta-Image' diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index d21bfb5a71a32..8765ee6c82260 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -10,12 +10,7 @@ from typing import List, Dict, Any, TYPE_CHECKING # noqa pylint: disable=unused-import from homeassistant import monkey_patch -from homeassistant.const import ( - __version__, - EVENT_HOMEASSISTANT_START, - REQUIRED_PYTHON_VER, - RESTART_EXIT_CODE, -) +from homeassistant.const import __version__, REQUIRED_PYTHON_VER, RESTART_EXIT_CODE if TYPE_CHECKING: from homeassistant import core @@ -173,7 +168,7 @@ def get_arguments() -> argparse.Namespace: parser.add_argument( "--runner", action="store_true", - help="On restart exit with code {}".format(RESTART_EXIT_CODE), + help=f"On restart exit with code {RESTART_EXIT_CODE}", ) parser.add_argument( "--script", nargs=argparse.REMAINDER, help="Run one of the embedded scripts" @@ -245,7 +240,7 @@ def write_pid(pid_file: str) -> None: with open(pid_file, "w") as file: file.write(str(pid)) except IOError: - print("Fatal Error: Unable to write pid file {}".format(pid_file)) + print(f"Fatal Error: Unable to write pid file {pid_file}") sys.exit(1) @@ -309,23 +304,10 @@ async def setup_and_run_hass(config_dir: str, args: argparse.Namespace) -> int: log_no_color=args.log_no_color, ) - if args.open_ui: - # Imported here to avoid importing asyncio before monkey patch - from homeassistant.util.async_ import run_callback_threadsafe + if args.open_ui and hass.config.api is not None: + import webbrowser - def open_browser(_: Any) -> None: - """Open the web interface in a browser.""" - if hass.config.api is not None: - import webbrowser - - webbrowser.open(hass.config.api.base_url) - - run_callback_threadsafe( - hass.loop, - hass.bus.async_listen_once, - EVENT_HOMEASSISTANT_START, - open_browser, - ) + hass.add_job(webbrowser.open, hass.config.api.base_url) return await hass.async_run() @@ -344,7 +326,7 @@ def try_to_restart() -> None: thread.is_alive() and not thread.daemon for thread in threading.enumerate() ) if nthreads > 1: - sys.stderr.write("Found {} non-daemonic threads.\n".format(nthreads)) + sys.stderr.write(f"Found {nthreads} non-daemonic threads.\n") # Somehow we sometimes seem to trigger an assertion in the python threading # module. It seems we find threads that have no associated OS level thread diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 2641f0b8f7ee4..e2778e9f45b58 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -278,9 +278,7 @@ async def async_enable_user_mfa( module = self.get_auth_mfa_module(mfa_module_id) if module is None: - raise ValueError( - "Unable find multi-factor auth module: {}".format(mfa_module_id) - ) + raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_setup_user(user.id, data) @@ -295,9 +293,7 @@ async def async_disable_user_mfa( module = self.get_auth_mfa_module(mfa_module_id) if module is None: - raise ValueError( - "Unable find multi-factor auth module: {}".format(mfa_module_id) - ) + raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_depose_user(user.id) @@ -356,7 +352,7 @@ async def async_create_refresh_token( ): # Each client_name can only have one # long_lived_access_token type of refresh token - raise ValueError("{} already exists".format(client_name)) + raise ValueError(f"{client_name} already exists") return await self._store.async_create_refresh_token( user, diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 82db0bcf7a90f..894819fb3c7bf 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -94,7 +94,7 @@ async def async_create_user( for group_id in group_ids or []: group = self._groups.get(group_id) if group is None: - raise ValueError("Invalid group specified {}".format(group_id)) + raise ValueError(f"Invalid group specified {group_id}") groups.append(group) kwargs = { diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index 5481b8fe08bfa..baccedeabbff2 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -144,15 +144,13 @@ async def auth_mfa_module_from_config( async def _load_mfa_module(hass: HomeAssistant, module_name: str) -> types.ModuleType: """Load an mfa auth module.""" - module_path = "homeassistant.auth.mfa_modules.{}".format(module_name) + module_path = f"homeassistant.auth.mfa_modules.{module_name}" try: module = importlib.import_module(module_path) except ImportError as err: _LOGGER.error("Unable to load mfa module %s: %s", module_name, err) - raise HomeAssistantError( - "Unable to load mfa module {}: {}".format(module_name, err) - ) + raise HomeAssistantError(f"Unable to load mfa module {module_name}: {err}") if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index c35af2e0b96ac..ee9ef8f94cd0c 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -144,14 +144,10 @@ async def load_auth_provider_module( ) -> types.ModuleType: """Load an auth provider.""" try: - module = importlib.import_module( - "homeassistant.auth.providers.{}".format(provider) - ) + module = importlib.import_module(f"homeassistant.auth.providers.{provider}") except ImportError as err: _LOGGER.error("Unable to load auth provider %s: %s", provider, err) - raise HomeAssistantError( - "Unable to load auth provider {}: {}".format(provider, err) - ) + raise HomeAssistantError(f"Unable to load auth provider {provider}: {err}") if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module @@ -166,7 +162,7 @@ async def load_auth_provider_module( # https://github.com/python/mypy/issues/1424 reqs = module.REQUIREMENTS # type: ignore await requirements.async_process_requirements( - hass, "auth provider {}".format(provider), reqs + hass, f"auth provider {provider}", reqs ) processed.add(provider) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index b0eab0da0f32c..3e71a588af0c0 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -163,7 +163,7 @@ def async_enable_logging( # ensure that the handlers it sets up wraps the correct streams. logging.basicConfig(level=logging.INFO) - colorfmt = "%(log_color)s{}%(reset)s".format(fmt) + colorfmt = f"%(log_color)s{fmt}%(reset)s" logging.getLogger().handlers[0].setFormatter( ColoredFormatter( colorfmt, diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index e0d4e29a8e568..8e5ddb924ca65 100644 --- a/homeassistant/components/ambiclimate/manifest.json +++ b/homeassistant/components/ambiclimate/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/ambiclimate", "requirements": [ - "ambiclimate==0.2.0" + "ambiclimate==0.2.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 24eb61d52b00e..047eaaaf5db9c 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,7 +3,7 @@ "name": "Androidtv", "documentation": "https://www.home-assistant.io/components/androidtv", "requirements": [ - "androidtv==0.0.23" + "androidtv==0.0.24" ], "dependencies": [], "codeowners": ["@JeffLIrion"] diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index ef9293381fd49..db4ff9e851ec8 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -270,6 +270,9 @@ def __init__(self, aftv, name, apps, turn_on_command, turn_off_command): self._apps.update(apps) self._keys = KEYS + self._device_properties = self.aftv.device_properties + self._unique_id = self._device_properties.get("serialno") + self.turn_on_command = turn_on_command self.turn_off_command = turn_off_command @@ -338,6 +341,11 @@ def state(self): """Return the state of the player.""" return self._state + @property + def unique_id(self): + """Return the device unique id.""" + return self._unique_id + @adb_decorator() def media_play(self): """Send play command.""" @@ -412,9 +420,7 @@ def __init__(self, aftv, name, apps, turn_on_command, turn_off_command): super().__init__(aftv, name, apps, turn_on_command, turn_off_command) self._device = None - self._device_properties = self.aftv.device_properties self._is_volume_muted = None - self._unique_id = self._device_properties.get("serialno") self._volume_level = None @adb_decorator(override_available=True) @@ -454,11 +460,6 @@ def supported_features(self): """Flag media player features that are supported.""" return SUPPORT_ANDROIDTV - @property - def unique_id(self): - """Return the device unique id.""" - return self._unique_id - @property def volume_level(self): """Return the volume level.""" diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ee99153510471..d4faa55ed8c05 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -138,7 +138,7 @@ async def forward_events(event): if payload is stop_obj: break - msg = "data: {}\n\n".format(payload) + msg = f"data: {payload}\n\n" _LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip()) await response.write(msg.encode("UTF-8")) except asyncio.TimeoutError: @@ -316,7 +316,7 @@ async def post(self, request, event_type): event_type, event_data, ha.EventOrigin.remote, self.context(request) ) - return self.json_message("Event {} fired.".format(event_type)) + return self.json_message(f"Event {event_type} fired.") class APIServicesView(HomeAssistantView): @@ -388,7 +388,7 @@ async def post(self, request): return tpl.async_render(data.get("variables")) except (ValueError, TemplateError) as ex: return self.json_message( - "Error rendering template: {}".format(ex), HTTP_BAD_REQUEST + f"Error rendering template: {ex}", HTTP_BAD_REQUEST ) diff --git a/homeassistant/components/atome/__init__.py b/homeassistant/components/atome/__init__.py new file mode 100644 index 0000000000000..6f524606a817b --- /dev/null +++ b/homeassistant/components/atome/__init__.py @@ -0,0 +1 @@ +"""Support for Atome devices connected to a Linky Energy Meter.""" diff --git a/homeassistant/components/atome/manifest.json b/homeassistant/components/atome/manifest.json new file mode 100644 index 0000000000000..1a46aca6de6ad --- /dev/null +++ b/homeassistant/components/atome/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "atome", + "name": "Atome", + "documentation": "https://www.home-assistant.io/components/atome", + "dependencies": [], + "codeowners": ["@baqs"], + "requirements": ["pyatome==0.0.15"] +} diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py new file mode 100644 index 0000000000000..bff79f20e68ce --- /dev/null +++ b/homeassistant/components/atome/sensor.py @@ -0,0 +1,113 @@ +"""Linky Atome.""" +import logging +from datetime import timedelta +from pyatome.client import AtomeClient, PyAtomeError +import voluptuous as vol + + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT, CONF_NAME +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "atome" +DEFAULT_UNIT = "W" +DEFAULT_CLASS = "power" + +SCAN_INTERVAL = timedelta(seconds=30) +SESSION_RENEW_INTERVAL = timedelta(minutes=55) +DEFAULT_TIMEOUT = 10 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the sensor.""" + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + try: + client = AtomeClient(username, password) + except PyAtomeError as exp: + _LOGGER.error(exp) + return False + # finally: + # client.close_session() + + add_entities([AtomeSensor(name, client)]) + return True + + +class AtomeSensor(Entity): + """Representation of a sensor entity for Atome.""" + + def __init__(self, name, client: AtomeClient): + """Initialize the sensor.""" + _LOGGER.debug("ATOME: INIT : %s", str(client)) + self._name = name + # self._unit = DEFAULT_UNIT + self._unit_of_measurement = DEFAULT_UNIT + self._device_class = DEFAULT_CLASS + + self._client = client + + self._attributes = None + self._state = None + self._login() + self._get_data() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name or DEFAULT_NAME + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + # @Throttle(SESSION_RENEW_INTERVAL) + def _login(self): + + return self._client.login() + + def _get_data(self): + + return self._client.get_live() + + @Throttle(SCAN_INTERVAL) + def update(self): + """Update device state.""" + _LOGGER.debug("ATOME: Starting update of Atome Data") + + try: + values = self._get_data() + self._state = values["last"] + + except KeyError as error: + _LOGGER.error( + "Key error (%s), it seems the 'last' value is not accessible. Here is what I got: %s", + str(error), + str(values), + ) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 5de9336d1d97e..1cffd361b1921 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -143,7 +143,7 @@ async def trigger_service_handler(service_call): async def turn_onoff_service_handler(service_call): """Handle automation turn on/off service calls.""" tasks = [] - method = "async_{}".format(service_call.service) + method = f"async_{service_call.service}" for entity in await component.async_extract_from_service(service_call): tasks.append(getattr(entity, method)()) @@ -378,7 +378,7 @@ async def _async_process_config(hass, config, component): for list_no, config_block in enumerate(conf): automation_id = config_block.get(CONF_ID) - name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, list_no) + name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}" hidden = config_block[CONF_HIDE_ENTITY] initial_state = config_block.get(CONF_INITIAL_STATE) @@ -431,7 +431,7 @@ async def action(entity_id, variables, context): await script_obj.async_run(variables, context) except Exception as err: # pylint: disable=broad-except script_obj.async_log_exception( - _LOGGER, "Error while executing automation {}".format(entity_id), err + _LOGGER, f"Error while executing automation {entity_id}", err ) return action diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 9b44012e7580f..c257470bb2d0e 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -143,7 +143,10 @@ def update(self, *_): for listener in self._update_listeners: listener() except IOError as exception: - _LOGGER.error("Error updating the vehicle state") + _LOGGER.error( + "Could not connect to the BMW Connected Drive portal. " + "The vehicle state could not be updated." + ) _LOGGER.exception(exception) def add_update_listener(self, listener): diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index d52bec330fba7..418ccbabffe17 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -9,17 +9,17 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { - "lids": ["Doors", "opening"], - "windows": ["Windows", "opening"], - "door_lock_state": ["Door lock state", "safety"], - "lights_parking": ["Parking lights", "light"], - "condition_based_services": ["Condition based services", "problem"], - "check_control_messages": ["Control messages", "problem"], + "lids": ["Doors", "opening", "mdi:car-door"], + "windows": ["Windows", "opening", "mdi:car-door"], + "door_lock_state": ["Door lock state", "safety", "mdi:car-key"], + "lights_parking": ["Parking lights", "light", "mdi:car-parking-lights"], + "condition_based_services": ["Condition based services", "problem", "mdi:wrench"], + "check_control_messages": ["Control messages", "problem", "mdi:car-tire-alert"], } SENSOR_TYPES_ELEC = { - "charging_status": ["Charging status", "power"], - "connection_status": ["Connection status", "plug"], + "charging_status": ["Charging status", "power", "mdi:ev-station"], + "connection_status": ["Connection status", "plug", "mdi:car-electric"], } SENSOR_TYPES_ELEC.update(SENSOR_TYPES) @@ -35,24 +35,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if vehicle.has_hv_battery: _LOGGER.debug("BMW with a high voltage battery") for key, value in sorted(SENSOR_TYPES_ELEC.items()): - device = BMWConnectedDriveSensor( - account, vehicle, key, value[0], value[1] - ) - devices.append(device) + if key in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, key, value[0], value[1], value[2] + ) + devices.append(device) elif vehicle.has_internal_combustion_engine: _LOGGER.debug("BMW with an internal combustion engine") for key, value in sorted(SENSOR_TYPES.items()): - device = BMWConnectedDriveSensor( - account, vehicle, key, value[0], value[1] - ) - devices.append(device) + if key in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, key, value[0], value[1], value[2] + ) + devices.append(device) add_entities(devices, True) class BMWConnectedDriveSensor(BinarySensorDevice): """Representation of a BMW vehicle binary sensor.""" - def __init__(self, account, vehicle, attribute: str, sensor_name, device_class): + def __init__( + self, account, vehicle, attribute: str, sensor_name, device_class, icon + ): """Constructor.""" self._account = account self._vehicle = vehicle @@ -61,6 +65,7 @@ def __init__(self, account, vehicle, attribute: str, sensor_name, device_class): self._unique_id = "{}-{}".format(self._vehicle.vin, self._attribute) self._sensor_name = sensor_name self._device_class = device_class + self._icon = icon self._state = None @property @@ -81,6 +86,11 @@ def name(self): """Return the name of the binary sensor.""" return self._name + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + @property def device_class(self): """Return the class of the binary sensor.""" @@ -112,23 +122,19 @@ def device_state_attributes(self): for report in vehicle_state.condition_based_services: result.update(self._format_cbs_report(report)) elif self._attribute == "check_control_messages": - check_control_messages = vehicle_state.check_control_messages - if not check_control_messages: - result["check_control_messages"] = "OK" - else: + check_control_messages = vehicle_state.has_check_control_messages + if check_control_messages: cbs_list = [] for message in check_control_messages: cbs_list.append(message["ccmDescriptionShort"]) result["check_control_messages"] = cbs_list + else: + result["check_control_messages"] = "OK" elif self._attribute == "charging_status": result["charging_status"] = vehicle_state.charging_status.value - # pylint: disable=protected-access - result["last_charging_end_result"] = vehicle_state._attributes[ - "lastChargingEndResult" - ] - if self._attribute == "connection_status": - # pylint: disable=protected-access - result["connection_status"] = vehicle_state._attributes["connectionStatus"] + result["last_charging_end_result"] = vehicle_state.last_charging_end_result + elif self._attribute == "connection_status": + result["connection_status"] = vehicle_state.connection_status return sorted(result.items()) @@ -166,8 +172,7 @@ def update(self): # device class plug: On means device is plugged in, # Off means device is unplugged if self._attribute == "connection_status": - # pylint: disable=protected-access - self._state = vehicle_state._attributes["connectionStatus"] == "CONNECTED" + self._state = vehicle_state.connection_status == "CONNECTED" def _format_cbs_report(self, report): result = {} diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json old mode 100755 new mode 100644 index ad5f712f81773..0cc875c50f9f5 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -1,11 +1,12 @@ { "domain": "bmw_connected_drive", - "name": "Bmw connected drive", + "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/components/bmw_connected_drive", "requirements": [ - "bimmer_connected==0.5.6" + "bimmer_connected==0.6.0" ], "dependencies": [], "codeowners": [ + "@gerard33" ] -} +} \ No newline at end of file diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index bc133fa403416..8248ded4f8bce 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -51,14 +51,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for account in accounts: for vehicle in account.account.vehicles: for attribute_name in vehicle.drive_train_attributes: - device = BMWConnectedDriveSensor( - account, vehicle, attribute_name, attribute_info - ) - devices.append(device) - device = BMWConnectedDriveSensor( - account, vehicle, "mileage", attribute_info - ) - devices.append(device) + if attribute_name in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, attribute_name, attribute_info + ) + devices.append(device) add_entities(devices, True) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 1b2bfb5fdb136..597d67fcdeec6 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -7,7 +7,6 @@ import logging import hashlib from random import SystemRandom -from typing import Deque import attr from aiohttp import web @@ -315,7 +314,7 @@ def __init__(self): """Initialize a camera.""" self.is_streaming = False self.content_type = DEFAULT_CONTENT_TYPE - self.access_tokens: Deque[str] = collections.deque([], 2) + self.access_tokens: collections.deque = collections.deque([], 2) self.async_update_token() @property diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 58739bededc7b..3daeac43da942 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Cloud", "documentation": "https://www.home-assistant.io/components/cloud", - "requirements": ["hass-nabucasa==0.16"], + "requirements": ["hass-nabucasa==0.17"], "dependencies": ["http", "webhook"], "codeowners": ["@home-assistant/cloud"] } diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 5de11a032c5f7..6d4b465fceb3d 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -36,7 +36,7 @@ async def async_setup(hass, config): async def setup_panel(panel_name): """Set up a panel.""" - panel = importlib.import_module(".{}".format(panel_name), __name__) + panel = importlib.import_module(f".{panel_name}", __name__) if not panel: return @@ -44,7 +44,7 @@ async def setup_panel(panel_name): success = await panel.async_setup(hass) if success: - key = "{}.{}".format(DOMAIN, panel_name) + key = f"{DOMAIN}.{panel_name}" hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) @callback @@ -82,8 +82,8 @@ def __init__( post_write_hook=None, ): """Initialize a config view.""" - self.url = "/api/config/%s/%s/{config_key}" % (component, config_type) - self.name = "api:config:%s:%s" % (component, config_type) + self.url = f"/api/config/{component}/{config_type}/{{config_key}}" + self.name = f"api:config:{component}:{config_type}" self.path = path self.key_schema = key_schema self.data_schema = data_schema @@ -126,14 +126,14 @@ async def post(self, request, config_key): try: self.key_schema(config_key) except vol.Invalid as err: - return self.json_message("Key malformed: {}".format(err), 400) + return self.json_message(f"Key malformed: {err}", 400) try: # We just validate, we don't store that data because # we don't want to store the defaults. self.data_schema(data) except vol.Invalid as err: - return self.json_message("Message malformed: {}".format(err), 400) + return self.json_message(f"Message malformed: {err}", 400) hass = request.app["hass"] path = hass.config.path(self.path) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d7c8a6ea8e08c..b21991a847918 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,4 +1,5 @@ """Http views to control the config manager.""" +import aiohttp.web_exceptions import voluptuous as vol from homeassistant import config_entries, data_entry_flow @@ -28,6 +29,7 @@ async def async_setup(hass): OptionManagerFlowResourceView(hass.config_entries.options.flow) ) + hass.components.websocket_api.async_register_command(config_entries_progress) hass.components.websocket_api.async_register_command(system_options_list) hass.components.websocket_api.async_register_command(system_options_update) @@ -116,23 +118,8 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): name = "api:config:config_entries:flow" async def get(self, request): - """List flows that are in progress but not started by a user. - - Example of a non-user initiated flow is a discovered Hue hub that - requires user interaction to finish setup. - """ - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - - hass = request.app["hass"] - - return self.json( - [ - flw - for flw in hass.config_entries.flow.async_progress() - if flw["context"]["source"] != config_entries.SOURCE_USER - ] - ) + """Not implemented.""" + raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) # pylint: disable=arguments-differ async def post(self, request): @@ -241,6 +228,24 @@ async def post(self, request, flow_id): return await super().post(request, flow_id) +@websocket_api.require_admin +@websocket_api.websocket_command({"type": "config_entries/flow/progress"}) +def config_entries_progress(hass, connection, msg): + """List flows that are in progress but not started by a user. + + Example of a non-user initiated flow is a discovered Hue hub that + requires user interaction to finish setup. + """ + connection.send_result( + msg["id"], + [ + flw + for flw in hass.config_entries.flow.async_progress() + if flw["context"]["source"] != config_entries.SOURCE_USER + ], + ) + + @websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( @@ -272,7 +277,11 @@ async def system_options_update(hass, connection, msg): entry_id = changes.pop("entry_id") entry = hass.config_entries.async_get_entry(entry_id) - if entry and changes: - entry.system_options.update(**changes) + if entry is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" + ) + return - connection.send_result(msg["id"], entry.system_options.as_dict()) + hass.config_entries.async_update_entry(entry, system_options=changes) + connection.send_result(msg["id"], entry.system_options.as_dict()) diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index 99995959c2379..f3b2a41e9177a 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -61,10 +61,10 @@ def async_request_config( Will return an ID to be used for sequent calls. """ if link_name is not None and link_url is not None: - description += "\n\n[{}]({})".format(link_name, link_url) + description += f"\n\n[{link_name}]({link_url})" if description_image is not None: - description += "\n\n![Description image]({})".format(description_image) + description += f"\n\n![Description image]({description_image})" instance = hass.data.get(_KEY_INSTANCE) diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index 7379d66777b01..8a319c655f6f0 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -7,7 +7,7 @@ from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA from homeassistant.components.climate.const import ( HVAC_MODE_OFF, - HVAC_MODE_AUTO, + HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, @@ -33,14 +33,14 @@ HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_DRY, - HVAC_MODE_AUTO, + HVAC_MODE_HEAT_COOL, HVAC_MODE_FAN_ONLY, ] CM_TO_HA_STATE = { "heat": HVAC_MODE_HEAT, "cool": HVAC_MODE_COOL, - "auto": HVAC_MODE_AUTO, + "auto": HVAC_MODE_HEAT_COOL, "dry": HVAC_MODE_DRY, "fan": HVAC_MODE_FAN_ONLY, } diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index dd8f1cc4026ed..34da602a6cee5 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -40,5 +40,16 @@ } }, "title": "deCONZ Zigbee gateway" + }, + "options": { + "step": { + "deconz_devices": { + "description": "Configure visibility of deCONZ device types", + "data": { + "allow_clip_sensor": "Allow deCONZ CLIP sensors", + "allow_deconz_groups": "Allow deCONZ light groups" + } + } + } } -} \ No newline at end of file +} diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index f4b8d3ebe0262..650c02857509d 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure deCONZ component.""" import asyncio +from copy import copy import async_timeout import voluptuous as vol @@ -12,7 +13,13 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import CONF_BRIDGEID, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_ALLOW_CLIP_SENSOR, + CONF_ALLOW_DECONZ_GROUPS, + CONF_BRIDGEID, + DEFAULT_PORT, + DOMAIN, +) DECONZ_MANUFACTURERURL = "http://www.dresden-elektronik.de" CONF_SERIAL = "serial" @@ -45,6 +52,12 @@ class DeconzFlowHandler(config_entries.ConfigFlow): _hassio_discovery = None + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return DeconzOptionsFlowHandler(config_entry) + def __init__(self): """Initialize the deCONZ config flow.""" self.bridges = [] @@ -234,3 +247,41 @@ async def async_step_hassio_confirm(self, user_input=None): step_id="hassio_confirm", description_placeholders={"addon": self._hassio_discovery["addon"]}, ) + + +class DeconzOptionsFlowHandler(config_entries.OptionsFlow): + """Handle deCONZ options.""" + + def __init__(self, config_entry): + """Initialize deCONZ options flow.""" + self.config_entry = config_entry + self.options = copy(config_entry.options) + + async def async_step_init(self, user_input=None): + """Manage the deCONZ options.""" + return await self.async_step_deconz_devices() + + async def async_step_deconz_devices(self, user_input=None): + """Manage the deconz devices options.""" + if user_input is not None: + self.options[CONF_ALLOW_CLIP_SENSOR] = user_input[CONF_ALLOW_CLIP_SENSOR] + self.options[CONF_ALLOW_DECONZ_GROUPS] = user_input[ + CONF_ALLOW_DECONZ_GROUPS + ] + return self.async_create_entry(title="", data=self.options) + + return self.async_show_form( + step_id="deconz_devices", + data_schema=vol.Schema( + { + vol.Optional( + CONF_ALLOW_CLIP_SENSOR, + default=self.config_entry.options[CONF_ALLOW_CLIP_SENSOR], + ): bool, + vol.Optional( + CONF_ALLOW_DECONZ_GROUPS, + default=self.config_entry.options[CONF_ALLOW_DECONZ_GROUPS], + ): bool, + } + ), + ) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index d1c70793063ee..ea9ea2805155c 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -40,5 +40,16 @@ "one_instance_only": "Component only supports one deCONZ instance", "updated_instance": "Updated deCONZ instance with new host address" } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "description": "Configure visibility of deCONZ device types", + "data": { + "allow_clip_sensor": "Allow deCONZ CLIP sensors", + "allow_deconz_groups": "Allow deCONZ light groups" + } + } + } } } diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 7ac5fc17c6999..0cd77b6112eca 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -28,7 +28,7 @@ def camera_image(self): self._images_index = (self._images_index + 1) % 4 image_path = os.path.join( - os.path.dirname(__file__), "demo_{}.jpg".format(self._images_index) + os.path.dirname(__file__), f"demo_{self._images_index}.jpg" ) _LOGGER.debug("Loading camera_image: %s", image_path) with open(image_path, "rb") as file: diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index e3f69be3020f3..fb64f8015c0f4 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -417,7 +417,7 @@ def media_image_url(self): @property def media_title(self): """Return the title of current playing media.""" - return "Chapter {}".format(self._cur_episode) + return f"Chapter {self._cur_episode}" @property def media_series_title(self): diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index ffd3e768b116e..2ba704d39252b 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -244,7 +244,7 @@ def send_command(self, command, params=None, **kwargs): if self.supported_features & SUPPORT_SEND_COMMAND == 0: return - self._status = "Executing {}({})".format(command, params) + self._status = f"Executing {command}({params})" self._state = True self.schedule_update_ha_state() diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 7d67758017740..171d17faff9fa 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -1,13 +1,17 @@ """Integrate with DuckDNS.""" -from datetime import timedelta import logging +from asyncio import iscoroutinefunction +from datetime import timedelta import voluptuous as vol -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.core import callback, CALLBACK_TYPE from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.loader import bind_hass +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -42,25 +46,28 @@ async def async_setup(hass, config): token = config[DOMAIN][CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) - result = await _update_duckdns(session, domain, token) - - if not result: - return False - - async def update_domain_interval(now): + async def update_domain_interval(_now): """Update the DuckDNS entry.""" - await _update_duckdns(session, domain, token) + return await _update_duckdns(session, domain, token) + + intervals = ( + INTERVAL, + timedelta(minutes=1), + timedelta(minutes=5), + timedelta(minutes=15), + timedelta(minutes=30), + ) + async_track_time_interval_backoff(hass, update_domain_interval, intervals) async def update_domain_service(call): """Update the DuckDNS entry.""" await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT]) - async_track_time_interval(hass, update_domain_interval, INTERVAL) hass.services.async_register( DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA ) - return result + return True _SENTINEL = object() @@ -89,3 +96,37 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False) return False return True + + +@callback +@bind_hass +def async_track_time_interval_backoff(hass, action, intervals) -> CALLBACK_TYPE: + """Add a listener that fires repetitively at every timedelta interval.""" + if not iscoroutinefunction: + _LOGGER.error("action needs to be a coroutine and return True/False") + return + + if not isinstance(intervals, (list, tuple)): + intervals = (intervals,) + remove = None + failed = 0 + + async def interval_listener(now): + """Handle elapsed intervals with backoff.""" + nonlocal failed, remove + try: + failed += 1 + if await action(now): + failed = 0 + finally: + delay = intervals[failed] if failed < len(intervals) else intervals[-1] + remove = async_track_point_in_utc_time(hass, interval_listener, now + delay) + + hass.async_run_job(interval_listener, dt_util.utcnow()) + + def remove_listener(): + """Remove interval listener.""" + if remove: + remove() # pylint: disable=not-callable + + return remove_listener diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 1b08b43c9af63..fc00746fc7f49 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -562,17 +562,27 @@ def get_entity_state(config, entity): def entity_to_json(config, entity, state): """Convert an entity to its Hue bridge JSON representation.""" + entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if (entity_features & SUPPORT_BRIGHTNESS) or entity.domain != light.DOMAIN: + return { + "state": { + HUE_API_STATE_ON: state[STATE_ON], + HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_HUE: state[STATE_HUE], + HUE_API_STATE_SAT: state[STATE_SATURATION], + "reachable": True, + }, + "type": "Dimmable light", + "name": config.get_entity_name(entity), + "modelid": "HASS123", + "uniqueid": entity.entity_id, + "swversion": "123", + } return { - "state": { - HUE_API_STATE_ON: state[STATE_ON], - HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], - HUE_API_STATE_HUE: state[STATE_HUE], - HUE_API_STATE_SAT: state[STATE_SATURATION], - "reachable": True, - }, - "type": "Dimmable light", + "state": {HUE_API_STATE_ON: state[STATE_ON], "reachable": True}, + "type": "On/off light", "name": config.get_entity_name(entity), - "modelid": "HASS123", + "modelid": "HASS321", "uniqueid": entity.entity_id, "swversion": "123", } diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json old mode 100755 new mode 100644 index 26d732fc9278b..6d13c79bcec09 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/components/eq3btsmart", "requirements": [ "construct==2.9.45", - "python-eq3bt==0.1.11" + "python-eq3bt==0.1.9" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/essent/manifest.json b/homeassistant/components/essent/manifest.json index 41313cb44a918..aeb3b48311eff 100644 --- a/homeassistant/components/essent/manifest.json +++ b/homeassistant/components/essent/manifest.json @@ -2,7 +2,7 @@ "domain": "essent", "name": "Essent", "documentation": "https://www.home-assistant.io/components/essent", - "requirements": ["PyEssent==0.12"], + "requirements": ["PyEssent==0.13"], "dependencies": [], "codeowners": ["@TheLastProject"] } diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index f0e7a26e1f56b..0530878236236 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -273,10 +273,10 @@ def update(self, *args, **kwargs) -> None: else: self.timers["statusUpdated"] = utcnow() - _LOGGER.debug("Status = %s", status) + _LOGGER.debug("Status = %s", status) - # inform the evohome devices that state data has been updated - async_dispatcher_send(self.hass, DOMAIN, {"signal": "refresh"}) + # inform the evohome devices that state data has been updated + async_dispatcher_send(self.hass, DOMAIN, {"signal": "refresh"}) class EvoDevice(Entity): diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index f34cda67ad9b3..afe0aa3ed0242 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -85,6 +85,6 @@ def _update_info(self): if not self.success_init: return False - _LOGGER.info("Scanning") + _LOGGER.debug("Scanning") self.last_results = self.fritz_box.get_hosts_info() return True diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index d8790b746bec8..7298ce8c1d086 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -274,9 +274,7 @@ async def async_setup(hass, config): ("frontend_latest", True), ("frontend_es5", True), ): - hass.http.register_static_path( - "/{}".format(path), str(root_path / path), should_cache - ) + hass.http.register_static_path(f"/{path}", str(root_path / path), should_cache) hass.http.register_static_path( "/auth/authorize", str(root_path / "authorize.html"), False @@ -294,9 +292,7 @@ async def async_setup(hass, config): # To smooth transition to new urls, add redirects to new urls of dev tools # Added June 27, 2019. Can be removed in 2021. for panel in ("event", "info", "service", "state", "template", "mqtt"): - hass.http.register_redirect( - "/dev-{}".format(panel), "/developer-tools/{}".format(panel) - ) + hass.http.register_redirect(f"/dev-{panel}", f"/developer-tools/{panel}") async_register_built_in_panel( hass, diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2ccda8a1f4a1f..8d6271183bd87 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", "requirements": [ - "home-assistant-frontend==20190815.0" + "home-assistant-frontend==20190822.0" ], "dependencies": [ "api", diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index e4b723d595b43..45f3f91cd6d82 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -1,17 +1,23 @@ """Support for a Genius Hub system.""" from datetime import timedelta import logging +from typing import Awaitable import aiohttp import voluptuous as vol -from geniushubclient import GeniusHubClient +from geniushubclient import GeniusHub from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +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.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, + async_dispatcher_connect, +) +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) @@ -45,7 +51,7 @@ async def async_setup(hass, hass_config): broker = GeniusBroker(hass, args, kwargs) try: - await broker._client.hub.update() # pylint: disable=protected-access + await broker.client.update() except aiohttp.ClientResponseError as err: _LOGGER.error("Setup failed, check your configuration, %s", err) return False @@ -58,7 +64,7 @@ async def async_setup(hass, hass_config): async_load_platform(hass, platform, DOMAIN, {}, hass_config) ) - if broker._client.api_version == 3: # pylint: disable=protected-access + if broker.client.api_version == 3: # pylint: disable=no-member for platform in ["sensor", "binary_sensor"]: hass.async_create_task( async_load_platform(hass, platform, DOMAIN, {}, hass_config) @@ -72,27 +78,53 @@ class GeniusBroker: def __init__(self, hass, args, kwargs): """Initialize the geniushub client.""" - self._hass = hass - self._client = hass.data[DOMAIN]["client"] = GeniusHubClient( + self.hass = hass + self.client = hass.data[DOMAIN]["client"] = GeniusHub( *args, **kwargs, session=async_get_clientsession(hass) ) async def async_update(self, now, **kwargs): """Update the geniushub client's data.""" try: - await self._client.hub.update() + await self.client.update() except aiohttp.ClientResponseError as err: _LOGGER.warning("Update failed, %s", err) return self.make_debug_log_entries() - async_dispatcher_send(self._hass, DOMAIN) + async_dispatcher_send(self.hass, DOMAIN) def make_debug_log_entries(self): """Make any useful debug log entries.""" # pylint: disable=protected-access _LOGGER.debug( - "Raw JSON: \n\nhub._raw_zones = %s \n\nhub._raw_devices = %s", - self._client.hub._raw_zones, - self._client.hub._raw_devices, + "Raw JSON: \n\nclient._zones = %s \n\nclient._devices = %s", + self.client._zones, + self.client._devices, ) + + +class GeniusEntity(Entity): + """Base for all Genius Hub endtities.""" + + def __init__(self): + """Initialize the entity.""" + self._name = None + + async def async_added_to_hass(self) -> Awaitable[None]: + """Set up a listener when this entity is added to HA.""" + async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + + @callback + def _refresh(self) -> None: + self.async_schedule_update_ha_state(force_refresh=True) + + @property + def name(self) -> str: + """Return the name of the geniushub entity.""" + return self._name + + @property + def should_poll(self) -> bool: + """Return False as geniushub entities should not be polled.""" + return False diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index feb2e0da33e4d..1cc8cd3f4063b 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -1,10 +1,10 @@ """Support for Genius Hub binary_sensor devices.""" +from typing import Any, Dict + from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.dt import utc_from_timestamp -from . import DOMAIN +from . import DOMAIN, GeniusEntity GH_IS_SWITCH = ["Dual Channel Receiver", "Electric Switch", "Smart Plug"] @@ -14,58 +14,38 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= client = hass.data[DOMAIN]["client"] switches = [ - GeniusBinarySensor(client, d) - for d in client.hub.device_objs - if d.type[:21] in GH_IS_SWITCH + GeniusBinarySensor(d) for d in client.device_objs if d.type[:21] in GH_IS_SWITCH ] async_add_entities(switches) -class GeniusBinarySensor(BinarySensorDevice): +class GeniusBinarySensor(GeniusEntity, BinarySensorDevice): """Representation of a Genius Hub binary_sensor.""" - def __init__(self, client, device): + def __init__(self, device) -> None: """Initialize the binary sensor.""" - self._client = client - self._device = device + super().__init__() + self._device = device if device.type[:21] == "Dual Channel Receiver": self._name = "Dual Channel Receiver {}".format(device.id) else: self._name = "{} {}".format(device.type, device.id) - async def async_added_to_hass(self): - """Set up a listener when this entity is added to HA.""" - async_dispatcher_connect(self.hass, DOMAIN, self._refresh) - - @callback - def _refresh(self): - self.async_schedule_update_ha_state(force_refresh=True) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def should_poll(self) -> bool: - """Return False as the geniushub devices should not be polled.""" - return False - @property - def is_on(self): + def is_on(self) -> bool: """Return the status of the sensor.""" return self._device.data["state"]["outputOnOff"] @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the device state attributes.""" attrs = {} attrs["assigned_zone"] = self._device.data["assignedZones"][0]["name"] - # noqa; pylint: disable=protected-access - last_comms = self._device._raw_data["childValues"]["lastComms"]["val"] + # pylint: disable=protected-access + last_comms = self._device._raw["childValues"]["lastComms"]["val"] if last_comms != 0: attrs["last_comms"] = utc_from_timestamp(last_comms).isoformat() diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index cee737c09f808..a856e48438fcd 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -11,10 +11,8 @@ SUPPORT_PRESET_MODE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN +from . import DOMAIN, GeniusEntity ATTR_DURATION = "duration" @@ -38,32 +36,24 @@ async def async_setup_platform( client = hass.data[DOMAIN]["client"] entities = [ - GeniusClimateZone(client, z) for z in client.hub.zone_objs if z.type in GH_ZONES + GeniusClimateZone(z) for z in client.zone_objs if z.data["type"] in GH_ZONES ] async_add_entities(entities) -class GeniusClimateZone(ClimateDevice): +class GeniusClimateZone(GeniusEntity, ClimateDevice): """Representation of a Genius Hub climate device.""" - def __init__(self, client, zone): + def __init__(self, zone) -> None: """Initialize the climate device.""" - self._client = client - self._zone = zone + super().__init__() + self._zone = zone if hasattr(self._zone, "occupied"): # has a movement sensor self._preset_modes = list(HA_PRESET_TO_GH) else: self._preset_modes = [PRESET_BOOST] - async def async_added_to_hass(self) -> Awaitable[None]: - """Run when entity about to be added.""" - async_dispatcher_connect(self.hass, DOMAIN, self._refresh) - - @callback - def _refresh(self) -> None: - self.async_schedule_update_ha_state(force_refresh=True) - @property def name(self) -> str: """Return the name of the climate device.""" @@ -75,11 +65,6 @@ def device_state_attributes(self) -> Dict[str, Any]: tmp = self._zone.data.items() return {"status": {k: v for k, v in tmp if k in GH_STATE_ATTRS}} - @property - def should_poll(self) -> bool: - """Return False as the geniushub devices should not be polled.""" - return False - @property def icon(self) -> str: """Return the icon to use in the frontend UI.""" @@ -91,7 +76,7 @@ def current_temperature(self) -> Optional[float]: return self._zone.data["temperature"] @property - def target_temperature(self) -> Optional[float]: + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._zone.data["setpoint"] diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 0721c4ff3893b..12f7c266840bf 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -3,7 +3,7 @@ "name": "Genius Hub", "documentation": "https://www.home-assistant.io/components/geniushub", "requirements": [ - "geniushub-client==0.5.8" + "geniushub-client==0.6.5" ], "dependencies": [], "codeowners": ["@zxdavb"] diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index 65bfcb7fe9bed..5e39be1620a98 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -1,13 +1,11 @@ """Support for Genius Hub sensor devices.""" from datetime import timedelta +from typing import Any, Awaitable, Dict from homeassistant.const import DEVICE_CLASS_BATTERY -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity from homeassistant.util.dt import utc_from_timestamp, utcnow -from . import DOMAIN +from . import DOMAIN, GeniusEntity GH_HAS_BATTERY = ["Room Thermostat", "Genius Valve", "Room Sensor", "Radiator Valve"] @@ -22,44 +20,27 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the Genius Hub sensor entities.""" client = hass.data[DOMAIN]["client"] - sensors = [ - GeniusBattery(client, d) - for d in client.hub.device_objs - if d.type in GH_HAS_BATTERY - ] + sensors = [GeniusBattery(d) for d in client.device_objs if d.type in GH_HAS_BATTERY] issues = [GeniusIssue(client, i) for i in list(GH_LEVEL_MAPPING)] async_add_entities(sensors + issues, update_before_add=True) -class GeniusBattery(Entity): +class GeniusBattery(GeniusEntity): """Representation of a Genius Hub sensor.""" - def __init__(self, client, device): + def __init__(self, device) -> None: """Initialize the sensor.""" - self._client = client - self._device = device + super().__init__() + self._device = device self._name = "{} {}".format(device.type, device.id) - async def async_added_to_hass(self): - """Set up a listener when this entity is added to HA.""" - async_dispatcher_connect(self.hass, DOMAIN, self._refresh) - - @callback - def _refresh(self): - self.async_schedule_update_ha_state(force_refresh=True) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): + def icon(self) -> str: """Return the icon of the sensor.""" - # noqa; pylint: disable=protected-access - values = self._device._raw_data["childValues"] + + values = self._device._raw["childValues"] # pylint: disable=protected-access last_comms = utc_from_timestamp(values["lastComms"]["val"]) if "WakeUp_Interval" in values: @@ -83,78 +64,57 @@ def icon(self): return icon @property - def device_class(self): + def device_class(self) -> str: """Return the device class of the sensor.""" return DEVICE_CLASS_BATTERY @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit of measurement of the sensor.""" return "%" @property - def should_poll(self) -> bool: - """Return False as the geniushub devices should not be polled.""" - return False - - @property - def state(self): + def state(self) -> str: """Return the state of the sensor.""" level = self._device.data["state"].get("batteryLevel", 255) return level if level != 255 else 0 @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the device state attributes.""" attrs = {} attrs["assigned_zone"] = self._device.data["assignedZones"][0]["name"] - # noqa; pylint: disable=protected-access - last_comms = self._device._raw_data["childValues"]["lastComms"]["val"] + # pylint: disable=protected-access + last_comms = self._device._raw["childValues"]["lastComms"]["val"] attrs["last_comms"] = utc_from_timestamp(last_comms).isoformat() return {**attrs} -class GeniusIssue(Entity): +class GeniusIssue(GeniusEntity): """Representation of a Genius Hub sensor.""" - def __init__(self, client, level): + def __init__(self, hub, level) -> None: """Initialize the sensor.""" - self._hub = client.hub + super().__init__() + + self._hub = hub self._name = GH_LEVEL_MAPPING[level] self._level = level self._issues = [] - async def async_added_to_hass(self): - """Set up a listener when this entity is added to HA.""" - async_dispatcher_connect(self.hass, DOMAIN, self._refresh) - - @callback - def _refresh(self): - self.async_schedule_update_ha_state(force_refresh=True) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def should_poll(self) -> bool: - """Return False as the geniushub devices should not be polled.""" - return False - @property - def state(self): + def state(self) -> str: """Return the number of issues.""" return len(self._issues) @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the device state attributes.""" return {"{}_list".format(self._level): self._issues} - async def async_update(self): + async def async_update(self) -> Awaitable[None]: """Process the sensor's state data.""" self._issues = [ i["description"] for i in self._hub.issues if i["level"] == self._level diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index feb4235d4dd26..1086160e77c86 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -1,14 +1,14 @@ """Support for Genius Hub water_heater devices.""" +from typing import Any, Awaitable, Dict, Optional, List + from homeassistant.components.water_heater import ( WaterHeaterDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, ) from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN +from . import DOMAIN, GeniusEntity STATE_AUTO = "auto" STATE_MANUAL = "manual" @@ -44,93 +44,81 @@ async def async_setup_platform( client = hass.data[DOMAIN]["client"] entities = [ - GeniusWaterHeater(client, z) - for z in client.hub.zone_objs - if z.type in GH_HEATERS + GeniusWaterHeater(z) for z in client.zone_objs if z.data["type"] in GH_HEATERS ] async_add_entities(entities) -class GeniusWaterHeater(WaterHeaterDevice): +class GeniusWaterHeater(GeniusEntity, WaterHeaterDevice): """Representation of a Genius Hub water_heater device.""" - def __init__(self, client, boiler): + def __init__(self, boiler) -> None: """Initialize the water_heater device.""" - self._client = client - self._boiler = boiler + super().__init__() + self._boiler = boiler self._operation_list = list(HA_OPMODE_TO_GH) - async def async_added_to_hass(self): - """Run when entity about to be added.""" - async_dispatcher_connect(self.hass, DOMAIN, self._refresh) - - @callback - def _refresh(self): - self.async_schedule_update_ha_state(force_refresh=True) - @property - def name(self): + def name(self) -> str: """Return the name of the water_heater device.""" return self._boiler.name @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the device state attributes.""" - tmp = self._boiler.data.items() - return {"status": {k: v for k, v in tmp if k in GH_STATE_ATTRS}} - - @property - def should_poll(self) -> bool: - """Return False as the geniushub devices should not be polled.""" - return False + return { + "status": { + k: v for k, v in self._boiler.data.items() if k in GH_STATE_ATTRS + } + } @property - def current_temperature(self): + def current_temperature(self) -> Optional[float]: """Return the current temperature.""" return self._boiler.data.get("temperature") @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._boiler.data["setpoint"] @property - def min_temp(self): + def min_temp(self) -> float: """Return max valid temperature that can be set.""" return GH_MIN_TEMP @property - def max_temp(self): + def max_temp(self) -> float: """Return max valid temperature that can be set.""" return GH_MAX_TEMP @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property - def supported_features(self): + def supported_features(self) -> int: """Return the list of supported features.""" return GH_SUPPORT_FLAGS @property - def operation_list(self): + def operation_list(self) -> List[str]: """Return the list of available operation modes.""" return self._operation_list @property - def current_operation(self): + def current_operation(self) -> str: """Return the current operation mode.""" return GH_STATE_TO_HA[self._boiler.data["mode"]] - async def async_set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode) -> Awaitable[None]: """Set a new operation mode for this boiler.""" await self._boiler.set_mode(HA_OPMODE_TO_GH[operation_mode]) - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs) -> Awaitable[None]: """Set a new target temperature for this boiler.""" temperature = kwargs[ATTR_TEMPERATURE] await self._boiler.set_override(temperature, 3600) # 1 hour diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 0887aa19bfba6..2149e40e5045f 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -52,7 +52,7 @@ def __init__(self, hass, config: ConfigType, see) -> None: self.see = see self.username = config[CONF_USERNAME] self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] - self.scan_interval = timedelta(seconds=config.get(CONF_SCAN_INTERVAL, 60)) + self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(60) credfile = "{}.{}".format( hass.config.path(CREDENTIALS_FILE), slugify(self.username) diff --git a/homeassistant/components/googlehome/__init__.py b/homeassistant/components/googlehome/__init__.py deleted file mode 100644 index 01e17708fb35c..0000000000000 --- a/homeassistant/components/googlehome/__init__.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Support Google Home units.""" -import logging - -import asyncio -import voluptuous as vol -from homeassistant.const import CONF_DEVICES, CONF_HOST -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "googlehome" -CLIENT = "googlehome_client" - -NAME = "GoogleHome" - -CONF_DEVICE_TYPES = "device_types" -CONF_RSSI_THRESHOLD = "rssi_threshold" -CONF_TRACK_ALARMS = "track_alarms" -CONF_TRACK_DEVICES = "track_devices" - -DEVICE_TYPES = [1, 2, 3] -DEFAULT_RSSI_THRESHOLD = -70 - -DEVICE_CONFIG = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_DEVICE_TYPES, default=DEVICE_TYPES): vol.All( - cv.ensure_list, [vol.In(DEVICE_TYPES)] - ), - vol.Optional(CONF_RSSI_THRESHOLD, default=DEFAULT_RSSI_THRESHOLD): vol.Coerce( - int - ), - vol.Optional(CONF_TRACK_ALARMS, default=False): cv.boolean, - vol.Optional(CONF_TRACK_DEVICES, default=True): cv.boolean, - } -) - - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [DEVICE_CONFIG])} - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass, config): - """Set up the Google Home component.""" - hass.data[DOMAIN] = {} - hass.data[CLIENT] = GoogleHomeClient(hass) - - for device in config[DOMAIN][CONF_DEVICES]: - hass.data[DOMAIN][device["host"]] = {} - if device[CONF_TRACK_DEVICES]: - hass.async_create_task( - discovery.async_load_platform( - hass, "device_tracker", DOMAIN, device, config - ) - ) - - if device[CONF_TRACK_ALARMS]: - hass.async_create_task( - discovery.async_load_platform(hass, "sensor", DOMAIN, device, config) - ) - - return True - - -class GoogleHomeClient: - """Handle all communication with the Google Home unit.""" - - def __init__(self, hass): - """Initialize the Google Home Client.""" - self.hass = hass - self._connected = None - - async def update_info(self, host): - """Update data from Google Home.""" - from googledevices.api.connect import Cast - - _LOGGER.debug("Updating Google Home info for %s", host) - session = async_get_clientsession(self.hass) - - device_info = await Cast(host, self.hass.loop, session).info() - device_info_data = await device_info.get_device_info() - self._connected = bool(device_info_data) - - self.hass.data[DOMAIN][host]["info"] = device_info_data - - async def update_bluetooth(self, host): - """Update bluetooth from Google Home.""" - from googledevices.api.connect import Cast - - _LOGGER.debug("Updating Google Home bluetooth for %s", host) - session = async_get_clientsession(self.hass) - - bluetooth = await Cast(host, self.hass.loop, session).bluetooth() - await bluetooth.scan_for_devices() - await asyncio.sleep(5) - bluetooth_data = await bluetooth.get_scan_result() - - self.hass.data[DOMAIN][host]["bluetooth"] = bluetooth_data - - async def update_alarms(self, host): - """Update alarms from Google Home.""" - from googledevices.api.connect import Cast - - _LOGGER.debug("Updating Google Home bluetooth for %s", host) - session = async_get_clientsession(self.hass) - - assistant = await Cast(host, self.hass.loop, session).assistant() - alarms_data = await assistant.get_alarms() - - self.hass.data[DOMAIN][host]["alarms"] = alarms_data diff --git a/homeassistant/components/googlehome/device_tracker.py b/homeassistant/components/googlehome/device_tracker.py deleted file mode 100644 index 58350afa43090..0000000000000 --- a/homeassistant/components/googlehome/device_tracker.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Support for Google Home Bluetooth tacker.""" -from datetime import timedelta -import logging - -from homeassistant.components.device_tracker import DeviceScanner -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util import slugify - -from . import CLIENT, DOMAIN as GOOGLEHOME_DOMAIN, NAME - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Validate the configuration and return a Google Home scanner.""" - if discovery_info is None: - _LOGGER.warning("To use this you need to configure the 'googlehome' component") - return False - scanner = GoogleHomeDeviceScanner( - hass, hass.data[CLIENT], discovery_info, async_see - ) - return await scanner.async_init() - - -class GoogleHomeDeviceScanner(DeviceScanner): - """This class queries a Google Home unit.""" - - def __init__(self, hass, client, config, async_see): - """Initialize the scanner.""" - self.async_see = async_see - self.hass = hass - self.rssi = config["rssi_threshold"] - self.device_types = config["device_types"] - self.host = config["host"] - self.client = client - - async def async_init(self): - """Further initialize connection to Google Home.""" - await self.client.update_info(self.host) - data = self.hass.data[GOOGLEHOME_DOMAIN][self.host] - info = data.get("info", {}) - connected = bool(info) - if connected: - await self.async_update() - async_track_time_interval( - self.hass, self.async_update, DEFAULT_SCAN_INTERVAL - ) - return connected - - async def async_update(self, now=None): - """Ensure the information from Google Home is up to date.""" - _LOGGER.debug("Checking Devices on %s", self.host) - await self.client.update_bluetooth(self.host) - data = self.hass.data[GOOGLEHOME_DOMAIN][self.host] - info = data.get("info") - bluetooth = data.get("bluetooth") - if info is None or bluetooth is None: - return - google_home_name = info.get("name", NAME) - - for device in bluetooth: - if ( - device["device_type"] not in self.device_types - or device["rssi"] < self.rssi - ): - continue - - name = "{} {}".format(self.host, device["mac_address"]) - - attributes = {} - attributes["btle_mac_address"] = device["mac_address"] - attributes["ghname"] = google_home_name - attributes["rssi"] = device["rssi"] - attributes["source_type"] = "bluetooth" - if device["name"]: - attributes["name"] = device["name"] - - await self.async_see(dev_id=slugify(name), attributes=attributes) diff --git a/homeassistant/components/googlehome/manifest.json b/homeassistant/components/googlehome/manifest.json deleted file mode 100644 index 107e7d634f0f0..0000000000000 --- a/homeassistant/components/googlehome/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "domain": "googlehome", - "name": "Googlehome", - "documentation": "https://www.home-assistant.io/components/googlehome", - "requirements": [ - "googledevices==1.0.2" - ], - "dependencies": [], - "codeowners": [ - "@ludeeus" - ] -} diff --git a/homeassistant/components/googlehome/sensor.py b/homeassistant/components/googlehome/sensor.py deleted file mode 100644 index 6a578e14f5ae3..0000000000000 --- a/homeassistant/components/googlehome/sensor.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Support for Google Home alarm sensor.""" -from datetime import timedelta -import logging - -from homeassistant.const import DEVICE_CLASS_TIMESTAMP -from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util - -from . import CLIENT, DOMAIN as GOOGLEHOME_DOMAIN, NAME - -SCAN_INTERVAL = timedelta(seconds=10) - -_LOGGER = logging.getLogger(__name__) - -ICON = "mdi:alarm" - -SENSOR_TYPES = {"timer": "Timer", "alarm": "Alarm"} - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the googlehome sensor platform.""" - if discovery_info is None: - _LOGGER.warning("To use this you need to configure the 'googlehome' component") - return - - await hass.data[CLIENT].update_info(discovery_info["host"]) - data = hass.data[GOOGLEHOME_DOMAIN][discovery_info["host"]] - info = data.get("info", {}) - - devices = [] - for condition in SENSOR_TYPES: - device = GoogleHomeAlarm( - hass.data[CLIENT], condition, discovery_info, info.get("name", NAME) - ) - devices.append(device) - - async_add_entities(devices, True) - - -class GoogleHomeAlarm(Entity): - """Representation of a GoogleHomeAlarm.""" - - def __init__(self, client, condition, config, name): - """Initialize the GoogleHomeAlarm sensor.""" - self._host = config["host"] - self._client = client - self._condition = condition - self._name = None - self._state = None - self._available = True - self._name = "{} {}".format(name, SENSOR_TYPES[self._condition]) - - async def async_update(self): - """Update the data.""" - await self._client.update_alarms(self._host) - data = self.hass.data[GOOGLEHOME_DOMAIN][self._host] - - alarms = data.get("alarms")[self._condition] - if not alarms: - self._available = False - return - self._available = True - time_date = dt_util.utc_from_timestamp( - min(element["fire_time"] for element in alarms) / 1000 - ) - self._state = time_date.isoformat() - - @property - def state(self): - """Return the state.""" - return self._state - - @property - def name(self): - """Return the name.""" - return self._name - - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_TIMESTAMP - - @property - def available(self): - """Return the availability state.""" - return self._available - - @property - def icon(self): - """Return the icon.""" - return ICON diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 801c20b5c2bf0..6603728e0370f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -271,7 +271,7 @@ async def async_handle_core_service(call): hass.components.persistent_notification.async_create( "Config error. See dev-info panel for details.", "Config validating", - "{0}.check_config".format(HASS_DOMAIN), + f"{HASS_DOMAIN}.check_config", ) return diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 10f21556fb3ca..5213443614cbe 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -80,7 +80,7 @@ def get_addon_info(self, addon): This method return a coroutine. """ - return self.send_command("/addons/{}/info".format(addon), method="get") + return self.send_command(f"/addons/{addon}/info", method="get") @_api_data def get_ingress_panels(self): @@ -120,7 +120,7 @@ def get_discovery_message(self, uuid): This method return a coroutine. """ - return self.send_command("/discovery/{}".format(uuid), method="get") + return self.send_command(f"/discovery/{uuid}", method="get") @_api_bool async def update_hass_api(self, http_config, refresh_token): @@ -156,7 +156,7 @@ async def send_command(self, command, method="post", payload=None, timeout=10): with async_timeout.timeout(timeout): request = await self.websession.request( method, - "http://{}{}".format(self._ip, command), + f"http://{self._ip}{command}", json=payload, headers={X_HASSIO: os.environ.get("HASSIO_TOKEN", "")}, ) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index f42aaca44389d..3b1b83745105b 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -75,7 +75,7 @@ async def _command_proxy( method = getattr(self._websession, request.method.lower()) client = await method( - "http://{}/{}".format(self._host, path), + f"http://{self._host}/{path}", data=data, headers=headers, timeout=read_timeout, diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 84e2b0963624f..4ecb9a8419f52 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -42,7 +42,7 @@ def __init__(self, host: str, websession: aiohttp.ClientSession): def _create_url(self, token: str, path: str) -> str: """Create URL to service.""" - return "http://{}/ingress/{}/{}".format(self._host, token, path) + return f"http://{self._host}/ingress/{token}/{path}" async def _handle( self, request: web.Request, token: str, path: str @@ -91,7 +91,7 @@ async def _handle_websocket( # Support GET query if request.query_string: - url = "{}?{}".format(url, request.query_string) + url = f"{url}?{request.query_string}" # Start proxy async with self._websession.ws_connect( @@ -175,15 +175,15 @@ def _init_header( headers[X_HASSIO] = os.environ.get("HASSIO_TOKEN", "") # Ingress information - headers[X_INGRESS_PATH] = "/api/hassio_ingress/{}".format(token) + headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" # Set X-Forwarded-For forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) if forward_for: - forward_for = "{}, {!s}".format(forward_for, connected_ip) + forward_for = f"{forward_for}, {connected_ip!s}" else: - forward_for = "{!s}".format(connected_ip) + forward_for = f"{connected_ip!s}" headers[hdrs.X_FORWARDED_FOR] = forward_for # Set X-Forwarded-Host diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 7bb7718f0b358..97746f3f472b4 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -38,7 +38,7 @@ from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice -from .device import ATTR_GROUP_MEMBER_UNREACHABLE +from .device import ATTR_GROUP_MEMBER_UNREACHABLE, ATTR_MODEL_TYPE _LOGGER = logging.getLogger(__name__) @@ -309,7 +309,7 @@ def available(self) -> bool: @property def device_state_attributes(self): """Return the state attributes of the security zone group.""" - attr = super().device_state_attributes + attr = {ATTR_MODEL_TYPE: self._device.modelType} if self._device.motionDetected: attr[ATTR_MOTIONDETECTED] = True diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 021c264f63f60..b086eaa29c75f 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -11,12 +11,35 @@ _LOGGER = logging.getLogger(__name__) ATTR_MODEL_TYPE = "model_type" +ATTR_ID = "id" # RSSI HAP -> Device ATTR_RSSI_DEVICE = "rssi_device" # RSSI Device -> HAP ATTR_RSSI_PEER = "rssi_peer" ATTR_SABOTAGE = "sabotage" ATTR_GROUP_MEMBER_UNREACHABLE = "group_member_unreachable" +ATTR_DEVICE_OVERHEATED = "device_overheated" +ATTR_DEVICE_OVERLOADED = "device_overloaded" +ATTR_DEVICE_UNTERVOLTAGE = "device_undervoltage" + +DEVICE_ATTRIBUTE_ICONS = { + "lowBat": "mdi:battery-outline", + "sabotage": "mdi:alert", + "deviceOverheated": "mdi:alert", + "deviceOverloaded": "mdi:alert", + "deviceUndervoltage": "mdi:alert", +} + +DEVICE_ATTRIBUTES = { + "modelType": ATTR_MODEL_TYPE, + "id": ATTR_ID, + "sabotage": ATTR_SABOTAGE, + "rssiDeviceValue": ATTR_RSSI_DEVICE, + "rssiPeerValue": ATTR_RSSI_PEER, + "deviceOverheated": ATTR_DEVICE_OVERHEATED, + "deviceOverloaded": ATTR_DEVICE_OVERLOADED, + "deviceUndervoltage": ATTR_DEVICE_UNTERVOLTAGE, +} class HomematicipGenericDevice(Entity): @@ -84,20 +107,20 @@ def unique_id(self) -> str: @property def icon(self) -> Optional[str]: """Return the icon.""" - if hasattr(self._device, "lowBat") and self._device.lowBat: - return "mdi:battery-outline" - if hasattr(self._device, "sabotage") and self._device.sabotage: - return "mdi:alert" + for attr, icon in DEVICE_ATTRIBUTE_ICONS.items(): + if getattr(self._device, attr, None): + return icon + return None @property def device_state_attributes(self): """Return the state attributes of the generic device.""" - attr = {ATTR_MODEL_TYPE: self._device.modelType} - if hasattr(self._device, "sabotage") and self._device.sabotage: - attr[ATTR_SABOTAGE] = self._device.sabotage - if hasattr(self._device, "rssiDeviceValue") and self._device.rssiDeviceValue: - attr[ATTR_RSSI_DEVICE] = self._device.rssiDeviceValue - if hasattr(self._device, "rssiPeerValue") and self._device.rssiPeerValue: - attr[ATTR_RSSI_PEER] = self._device.rssiPeerValue - return attr + state_attr = {} + if isinstance(self._device, AsyncDevice): + for attr, attr_key in DEVICE_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + return state_attr diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index ee0d2cb1271bf..2a041ce6689e7 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,7 @@ "homematicip==0.10.10" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@SukramJ" + ] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index add03c6b64460..c15b3121d3a63 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -34,6 +34,7 @@ from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from .device import ATTR_MODEL_TYPE _LOGGER = logging.getLogger(__name__) @@ -142,6 +143,11 @@ def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" return "%" + @property + def device_state_attributes(self): + """Return the state attributes of the security zone group.""" + return {ATTR_MODEL_TYPE: self._device.modelType} + class HomematicipHeatingThermostat(HomematicipGenericDevice): """Representation of a HomematicIP heating thermostat device.""" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 5e474dafa076a..a8aaa3390a73d 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -133,12 +133,12 @@ def __init__( if host.startswith(("http://", "https://")): self.base_url = host elif use_ssl: - self.base_url = "https://{}".format(host) + self.base_url = f"https://{host}" else: - self.base_url = "http://{}".format(host) + self.base_url = f"http://{host}" if port is not None: - self.base_url += ":{}".format(port) + self.base_url += f":{port}" async def async_setup(hass, config): @@ -268,15 +268,11 @@ def register_view(self, view): if not hasattr(view, "url"): class_name = view.__class__.__name__ - raise AttributeError( - '{0} missing required attribute "url"'.format(class_name) - ) + raise AttributeError(f'{class_name} missing required attribute "url"') if not hasattr(view, "name"): class_name = view.__class__.__name__ - raise AttributeError( - '{0} missing required attribute "name"'.format(class_name) - ) + raise AttributeError(f'{class_name} missing required attribute "name"') view.register(self.app, self.app.router) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 71e7ff3892414..d8fa8853c7f18 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -127,7 +127,7 @@ async def process_wrong_login(request): _LOGGER.warning("Banned IP %s for too many login attempts", remote_addr) hass.components.persistent_notification.async_create( - "Too many login attempts from {}".format(remote_addr), + f"Too many login attempts from {remote_addr}", "Banning IP address", NOTIFICATION_ID_BAN, ) diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 634a96aa31235..5945a4ca402fb 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -43,9 +43,7 @@ async def wrapper(view, request, *args, **kwargs): kwargs["data"] = self._schema(data) except vol.Invalid as err: _LOGGER.error("Data does not match schema: %s", err) - return view.json_message( - "Message format incorrect: {}".format(err), 400 - ) + return view.json_message(f"Message format incorrect: {err}", 400) result = await method(view, request, *args, **kwargs) return result diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 76844407f7d11..952ca473fdc5f 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -10,7 +10,7 @@ # mypy: allow-untyped-defs CACHE_TIME = 31 * 86400 # = 1 month -CACHE_HEADERS = {hdrs.CACHE_CONTROL: "public, max-age={}".format(CACHE_TIME)} +CACHE_HEADERS = {hdrs.CACHE_CONTROL: f"public, max-age={CACHE_TIME}"} # https://github.com/PyCQA/astroid/issues/633 diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 4ef88eb783e7b..da78dc7d8cf7a 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -137,7 +137,9 @@ def format_default(value): unit = None if value is not None: # Clean up value and infer unit, e.g. -71dBm, 15 dB - match = re.match(r"(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value)) + match = re.match( + r"([>=<]*)(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) + ) if match: try: value = float(match.group("value")) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 1d058d84b6106..0b0e3723b138a 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -44,8 +44,7 @@ def _find_username_from_config(hass, filename): return None -@config_entries.HANDLERS.register(DOMAIN) -class HueFlowHandler(config_entries.ConfigFlow): +class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Hue config flow.""" VERSION = 1 diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 2564b8b31b41d..007ed6517efe4 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -49,13 +49,11 @@ def _cv_input_number(cfg): maximum = cfg.get(CONF_MAX) if minimum >= maximum: raise vol.Invalid( - "Maximum ({}) is not greater than minimum ({})".format(minimum, maximum) + f"Maximum ({minimum}) is not greater than minimum ({maximum})" ) state = cfg.get(CONF_INITIAL) if state is not None and (state < minimum or state > maximum): - raise vol.Invalid( - "Initial value {} not in range {}-{}".format(state, minimum, maximum) - ) + raise vol.Invalid(f"Initial value {state} not in range {minimum}-{maximum}") return cfg diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 2b7c7312f71ca..fc49bd65ced92 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -45,12 +45,12 @@ def _cv_input_text(cfg): maximum = cfg.get(CONF_MAX) if minimum > maximum: raise vol.Invalid( - "Max len ({}) is not greater than min len ({})".format(minimum, maximum) + f"Max len ({minimum}) is not greater than min len ({maximum})" ) state = cfg.get(CONF_INITIAL) if state is not None and (len(state) < minimum or len(state) > maximum): raise vol.Invalid( - "Initial value {} length not in range {}-{}".format(state, minimum, maximum) + f"Initial value {state} length not in range {minimum}-{maximum}" ) return cfg diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index d24b70c4be01c..236a996794a7a 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -94,7 +94,7 @@ def __init__( self._state = 0 self._method = integration_method - self._name = name if name is not None else "{} integral".format(source_entity) + self._name = name if name is not None else f"{source_entity} integral" if unit_of_measurement is None: self._unit_template = "{}{}{}".format( diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 443a4cbc854a1..75a0c0e8f976e 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -55,7 +55,7 @@ async def async_setup(hass, config): for intent_type, conf in intents.items(): if CONF_ACTION in conf: conf[CONF_ACTION] = script.Script( - hass, conf[CONF_ACTION], "Intent Script {}".format(intent_type) + hass, conf[CONF_ACTION], f"Intent Script {intent_type}" ) intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index c0bc383abc01f..d1532066f68cd 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure IPMA component.""" import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME import homeassistant.helpers.config_validation as cv @@ -9,7 +9,7 @@ @config_entries.HANDLERS.register(DOMAIN) -class IpmaFlowHandler(data_entry_flow.FlowHandler): +class IpmaFlowHandler(config_entries.ConfigFlow): """Config flow for IPMA component.""" VERSION = 1 diff --git a/homeassistant/components/keba/__init__.py b/homeassistant/components/keba/__init__.py new file mode 100644 index 0000000000000..5a9a49a005a8d --- /dev/null +++ b/homeassistant/components/keba/__init__.py @@ -0,0 +1,229 @@ +"""Support for KEBA charging stations.""" +import asyncio +import logging + +from keba_kecontact.connection import KebaKeContact +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "keba" +SUPPORTED_COMPONENTS = ["binary_sensor", "sensor", "lock"] + +CONF_RFID = "rfid" +CONF_FS = "failsafe" +CONF_FS_TIMEOUT = "failsafe_timeout" +CONF_FS_FALLBACK = "failsafe_fallback" +CONF_FS_PERSIST = "failsafe_persist" +CONF_FS_INTERVAL = "refresh_interval" + +MAX_POLLING_INTERVAL = 5 # in seconds +MAX_FAST_POLLING_COUNT = 4 + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_RFID, default="00845500"): cv.string, + vol.Optional(CONF_FS, default=False): cv.boolean, + vol.Optional(CONF_FS_TIMEOUT, default=30): cv.positive_int, + vol.Optional(CONF_FS_FALLBACK, default=6): cv.positive_int, + vol.Optional(CONF_FS_PERSIST, default=0): cv.positive_int, + vol.Optional(CONF_FS_INTERVAL, default=5): cv.positive_int, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICE_MAP = { + "request_data": "request_data", + "set_energy": "async_set_energy", + "set_current": "async_set_current", + "authorize": "async_start", + "deauthorize": "async_stop", + "enable": "async_enable_ev", + "disable": "async_disable_ev", + "set_failsafe": "async_set_failsafe", +} + + +async def async_setup(hass, config): + """Check connectivity and version of KEBA charging station.""" + host = config[DOMAIN][CONF_HOST] + rfid = config[DOMAIN][CONF_RFID] + refresh_interval = config[DOMAIN][CONF_FS_INTERVAL] + keba = KebaHandler(hass, host, rfid, refresh_interval) + hass.data[DOMAIN] = keba + + # Wait for KebaHandler setup complete (initial values loaded) + if not await keba.setup(): + _LOGGER.error("Could not find a charging station at %s", host) + return False + + # Set failsafe mode at start up of home assistant + failsafe = config[DOMAIN][CONF_FS] + timeout = config[DOMAIN][CONF_FS_TIMEOUT] if failsafe else 0 + fallback = config[DOMAIN][CONF_FS_FALLBACK] if failsafe else 0 + persist = config[DOMAIN][CONF_FS_PERSIST] if failsafe else 0 + try: + hass.loop.create_task(keba.set_failsafe(timeout, fallback, persist)) + except ValueError as ex: + _LOGGER.warning("Could not set failsafe mode %s", ex) + + # Register services to hass + async def execute_service(call): + """Execute a service to KEBA charging station. + + This must be a member function as we need access to the keba + object here. + """ + function_name = _SERVICE_MAP[call.service] + function_call = getattr(keba, function_name) + await function_call(call.data) + + for service in _SERVICE_MAP: + hass.services.async_register(DOMAIN, service, execute_service) + + # Load components + for domain in SUPPORTED_COMPONENTS: + hass.async_create_task( + discovery.async_load_platform(hass, domain, DOMAIN, {}, config) + ) + + # Start periodic polling of charging station data + keba.start_periodic_request() + + return True + + +class KebaHandler(KebaKeContact): + """Representation of a KEBA charging station connection.""" + + def __init__(self, hass, host, rfid, refresh_interval): + """Constructor.""" + super().__init__(host, self.hass_callback) + + self._update_listeners = [] + self._hass = hass + self.rfid = rfid + self.device_name = "keba_wallbox_" + + # Ensure at least MAX_POLLING_INTERVAL seconds delay + self._refresh_interval = max(MAX_POLLING_INTERVAL, refresh_interval) + self._fast_polling_count = MAX_FAST_POLLING_COUNT + self._polling_task = None + + def start_periodic_request(self): + """Start periodic data polling.""" + self._polling_task = self._hass.loop.create_task(self._periodic_request()) + + async def _periodic_request(self): + """Send periodic update requests.""" + await self.request_data() + + if self._fast_polling_count < MAX_FAST_POLLING_COUNT: + self._fast_polling_count += 1 + _LOGGER.debug("Periodic data request executed, now wait for 2 seconds") + await asyncio.sleep(2) + else: + _LOGGER.debug( + "Periodic data request executed, now wait for %s seconds", + self._refresh_interval, + ) + await asyncio.sleep(self._refresh_interval) + + _LOGGER.debug("Periodic data request rescheduled") + self._polling_task = self._hass.loop.create_task(self._periodic_request()) + + async def setup(self, loop=None): + """Initialize KebaHandler object.""" + await super().setup(loop) + + # Request initial values and extract serial number + await self.request_data() + if self.get_value("Serial") is not None: + self.device_name = f"keba_wallbox_{self.get_value('Serial')}" + return True + + return False + + def hass_callback(self, data): + """Handle component notification via callback.""" + + # Inform entities about updated values + for listener in self._update_listeners: + listener() + + _LOGGER.debug("Notifying %d listeners", len(self._update_listeners)) + + def _set_fast_polling(self): + _LOGGER.debug("Fast polling enabled") + self._fast_polling_count = 0 + self._polling_task.cancel() + self._polling_task = self._hass.loop.create_task(self._periodic_request()) + + def add_update_listener(self, listener): + """Add a listener for update notifications.""" + self._update_listeners.append(listener) + + # initial data is already loaded, thus update the component + listener() + + async def async_set_energy(self, param): + """Set energy target in async way.""" + try: + energy = param["energy"] + await self.set_energy(energy) + self._set_fast_polling() + except (KeyError, ValueError) as ex: + _LOGGER.warning("Energy value is not correct. %s", ex) + + async def async_set_current(self, param): + """Set current maximum in async way.""" + try: + current = param["current"] + await self.set_current(current) + # No fast polling as this function might be called regularly + except (KeyError, ValueError) as ex: + _LOGGER.warning("Current value is not correct. %s", ex) + + async def async_start(self, param=None): + """Authorize EV in async way.""" + await self.start(self.rfid) + self._set_fast_polling() + + async def async_stop(self, param=None): + """De-authorize EV in async way.""" + await self.stop(self.rfid) + self._set_fast_polling() + + async def async_enable_ev(self, param=None): + """Enable EV in async way.""" + await self.enable(True) + self._set_fast_polling() + + async def async_disable_ev(self, param=None): + """Disable EV in async way.""" + await self.enable(False) + self._set_fast_polling() + + async def async_set_failsafe(self, param=None): + """Set failsafe mode in async way.""" + try: + timout = param[CONF_FS_TIMEOUT] + fallback = param[CONF_FS_FALLBACK] + persist = param[CONF_FS_PERSIST] + await self.set_failsafe(timout, fallback, persist) + self._set_fast_polling() + except (KeyError, ValueError) as ex: + _LOGGER.warning( + "failsafe_timeout, failsafe_fallback and/or " + "failsafe_persist value are not correct. %s", + ex, + ) diff --git a/homeassistant/components/keba/binary_sensor.py b/homeassistant/components/keba/binary_sensor.py new file mode 100644 index 0000000000000..8c0503a2020f0 --- /dev/null +++ b/homeassistant/components/keba/binary_sensor.py @@ -0,0 +1,108 @@ +"""Support for KEBA charging station binary sensors.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_PLUG, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SAFETY, +) + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the KEBA charging station platform.""" + if discovery_info is None: + return + + keba = hass.data[DOMAIN] + + sensors = [ + KebaBinarySensor(keba, "Online", "Wallbox", DEVICE_CLASS_CONNECTIVITY), + KebaBinarySensor(keba, "Plug", "Plug", DEVICE_CLASS_PLUG), + KebaBinarySensor(keba, "State", "Charging state", DEVICE_CLASS_POWER), + KebaBinarySensor(keba, "Tmo FS", "Failsafe Mode", DEVICE_CLASS_SAFETY), + ] + async_add_entities(sensors) + + +class KebaBinarySensor(BinarySensorDevice): + """Representation of a binary sensor of a KEBA charging station.""" + + def __init__(self, keba, key, sensor_name, device_class): + """Initialize the KEBA Sensor.""" + self._key = key + self._keba = keba + self._name = sensor_name + self._device_class = device_class + self._is_on = None + self._attributes = {} + + @property + def should_poll(self): + """Deactivate polling. Data updated by KebaHandler.""" + return False + + @property + def unique_id(self): + """Return the unique ID of the binary sensor.""" + return f"{self._keba.device_name}_{self._name}" + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._is_on + + @property + def device_state_attributes(self): + """Return the state attributes of the binary sensor.""" + return self._attributes + + async def async_update(self): + """Get latest cached states from the device.""" + if self._key == "Online": + self._is_on = self._keba.get_value(self._key) + + elif self._key == "Plug": + self._is_on = self._keba.get_value("Plug_plugged") + self._attributes["plugged_on_wallbox"] = self._keba.get_value( + "Plug_wallbox" + ) + self._attributes["plug_locked"] = self._keba.get_value("Plug_locked") + self._attributes["plugged_on_EV"] = self._keba.get_value("Plug_EV") + + elif self._key == "State": + self._is_on = self._keba.get_value("State_on") + self._attributes["status"] = self._keba.get_value("State_details") + self._attributes["max_charging_rate"] = str( + self._keba.get_value("Max curr") + ) + + elif self._key == "Tmo FS": + self._is_on = not self._keba.get_value("FS_on") + self._attributes["failsafe_timeout"] = str(self._keba.get_value("Tmo FS")) + self._attributes["fallback_current"] = str(self._keba.get_value("Curr FS")) + elif self._key == "Authreq": + self._is_on = self._keba.get_value(self._key) == 0 + + def update_callback(self): + """Schedule a state update.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Add update callback after being added to hass.""" + self._keba.add_update_listener(self.update_callback) diff --git a/homeassistant/components/keba/lock.py b/homeassistant/components/keba/lock.py new file mode 100644 index 0000000000000..3a65e44cd6fcb --- /dev/null +++ b/homeassistant/components/keba/lock.py @@ -0,0 +1,69 @@ +"""Support for KEBA charging station switch.""" +import logging + +from homeassistant.components.lock import LockDevice + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the KEBA charging station platform.""" + if discovery_info is None: + return + + keba = hass.data[DOMAIN] + + sensors = [KebaLock(keba, "Authentication")] + async_add_entities(sensors) + + +class KebaLock(LockDevice): + """The entity class for KEBA charging stations switch.""" + + def __init__(self, keba, name): + """Initialize the KEBA switch.""" + self._keba = keba + self._name = name + self._state = True + + @property + def should_poll(self): + """Deactivate polling. Data updated by KebaHandler.""" + return False + + @property + def unique_id(self): + """Return the unique ID of the binary sensor.""" + return f"{self._keba.device_name}_{self._name}" + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state + + async def async_lock(self, **kwargs): + """Lock wallbox.""" + await self._keba.async_stop() + + async def async_unlock(self, **kwargs): + """Unlock wallbox.""" + await self._keba.async_start() + + async def async_update(self): + """Attempt to retrieve on off state from the switch.""" + self._state = self._keba.get_value("Authreq") == 1 + + def update_callback(self): + """Schedule a state update.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Add update callback after being added to hass.""" + self._keba.add_update_listener(self.update_callback) diff --git a/homeassistant/components/keba/manifest.json b/homeassistant/components/keba/manifest.json new file mode 100644 index 0000000000000..9e959f35c9f32 --- /dev/null +++ b/homeassistant/components/keba/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "keba", + "name": "Keba Charging Station", + "documentation": "https://www.home-assistant.io/components/keba", + "requirements": ["keba-kecontact==0.2.0"], + "dependencies": [], + "codeowners": [ + "@dannerph" + ] +} diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py new file mode 100644 index 0000000000000..f46b2f0cf9023 --- /dev/null +++ b/homeassistant/components/keba/sensor.py @@ -0,0 +1,109 @@ +"""Support for KEBA charging station sensors.""" +import logging + +from homeassistant.const import ENERGY_KILO_WATT_HOUR +from homeassistant.helpers.entity import Entity +from homeassistant.const import DEVICE_CLASS_POWER + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the KEBA charging station platform.""" + if discovery_info is None: + return + + keba = hass.data[DOMAIN] + + sensors = [ + KebaSensor(keba, "Curr user", "Max current", "mdi:flash", "A"), + KebaSensor( + keba, "Setenergy", "Energy target", "mdi:gauge", ENERGY_KILO_WATT_HOUR + ), + KebaSensor(keba, "P", "Charging power", "mdi:flash", "kW", DEVICE_CLASS_POWER), + KebaSensor( + keba, "E pres", "Session energy", "mdi:gauge", ENERGY_KILO_WATT_HOUR + ), + KebaSensor(keba, "E total", "Total Energy", "mdi:gauge", ENERGY_KILO_WATT_HOUR), + ] + async_add_entities(sensors) + + +class KebaSensor(Entity): + """The entity class for KEBA charging stations sensors.""" + + def __init__(self, keba, key, name, icon, unit, device_class=None): + """Initialize the KEBA Sensor.""" + self._key = key + self._keba = keba + self._name = name + self._device_class = device_class + self._icon = icon + self._unit = unit + self._state = None + self._attributes = {} + + @property + def should_poll(self): + """Deactivate polling. Data updated by KebaHandler.""" + return False + + @property + def unique_id(self): + """Return the unique ID of the binary sensor.""" + return f"{self._keba.device_name}_{self._name}" + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Get the unit of measurement.""" + return self._unit + + @property + def device_state_attributes(self): + """Return the state attributes of the binary sensor.""" + return self._attributes + + async def async_update(self): + """Get latest cached states from the device.""" + self._state = self._keba.get_value(self._key) + + if self._key == "P": + self._attributes["power_factor"] = self._keba.get_value("PF") + self._attributes["voltage_u1"] = str(self._keba.get_value("U1")) + self._attributes["voltage_u2"] = str(self._keba.get_value("U2")) + self._attributes["voltage_u3"] = str(self._keba.get_value("U3")) + self._attributes["current_i1"] = str(self._keba.get_value("I1")) + self._attributes["current_i2"] = str(self._keba.get_value("I2")) + self._attributes["current_i3"] = str(self._keba.get_value("I3")) + elif self._key == "Curr user": + self._attributes["max_current_hardware"] = self._keba.get_value("Curr HW") + + def update_callback(self): + """Schedule a state update.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Add update callback after being added to hass.""" + self._keba.add_update_listener(self.update_callback) diff --git a/homeassistant/components/keba/services.yaml b/homeassistant/components/keba/services.yaml new file mode 100644 index 0000000000000..3422d6cf034d5 --- /dev/null +++ b/homeassistant/components/keba/services.yaml @@ -0,0 +1,56 @@ +# Describes the format for available services for KEBA charging staitons + +request_data: + description: > + Request new data from the charging station. + +authorize: + description: > + Authorizes a charging process with the predefined RFID tag of the configuration file. + +deauthorize: + description: > + Deauthorizes the running charging process with the predefined RFID tag of the configuration file. + +set_energy: + description: Sets the energy target after which the charging process stops. + fields: + energy: + description: > + The energy target to stop charging in kWh. Setting 0 disables the limit. + example: 10.0 + +set_current: + description: Sets the maximum current for charging processes. + fields: + current: + description: > + The maximum current used for the charging process in A. Allowed are values between + 6 A and 63 A. Invalid values are discardedand the default is set to 6 A. + The value is also depending on the DIP-switchsettings and the used cable of the + charging station + example: 16 +enable: + description: > + Starts a charging process if charging station is authorized. + +disable: + description: > + Stops the charging process if charging station is authorized. + +set_failsafe: + description: > + Set the failsafe mode of the charging station. If all parameters are 0, the failsafe mode will be disabled. + fields: + failsafe_timeout: + description: > + Timeout in seconds after which the failsafe mode is triggered, if set_current was not executed during this time. + example: 30 + failsafe_fallback: + description: > + Fallback current in A to be set after timeout. + example: 6 + failsafe_persist: + description: > + If failsafe_persist is 0, the failsafe option is only until charging station reboot. If failsafe_persist is 1, the failsafe option will survive a reboot. + example: 0 diff --git a/homeassistant/components/lacrosse/manifest.json b/homeassistant/components/lacrosse/manifest.json index 4716b3cb548e6..99dd488921336 100644 --- a/homeassistant/components/lacrosse/manifest.json +++ b/homeassistant/components/lacrosse/manifest.json @@ -3,7 +3,7 @@ "name": "Lacrosse", "documentation": "https://www.home-assistant.io/components/lacrosse", "requirements": [ - "pylacrosse==0.3.1" + "pylacrosse==0.4.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 9088d958cf0ca..795ba57d9887b 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure Met component.""" import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -17,8 +17,7 @@ def configured_instances(hass): ) -@config_entries.HANDLERS.register(DOMAIN) -class MetFlowHandler(data_entry_flow.FlowHandler): +class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Met component.""" VERSION = 1 diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 236892a98b916..aacd3c65b3eb6 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -18,6 +18,7 @@ from homeassistant.helpers.discovery import load_platform from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from .const import ( + NAME, DOMAIN, HOSTS, MTK_LOGIN_PLAIN, @@ -121,6 +122,7 @@ def __init__(self, host, use_ssl, port, user, password, login_method, encoding): self._password = password self._login_method = login_method self._encoding = encoding + self._ssl_wrapper = None self.hostname = None self._client = None self._connected = False @@ -137,10 +139,12 @@ def connect_to_device(self): } if self._use_ssl: - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - kwargs["ssl_wrapper"] = ssl_context.wrap_socket + if self._ssl_wrapper is None: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + self._ssl_wrapper = ssl_context.wrap_socket + kwargs["ssl_wrapper"] = self._ssl_wrapper try: self._client = librouteros.connect( @@ -163,7 +167,7 @@ def connect_to_device(self): def get_hostname(self): """Return device host name.""" data = self.command(MIKROTIK_SERVICES[IDENTITY]) - return data[0]["name"] if data else None + return data[0][NAME] if data else None def connected(self): """Return connected boolean.""" diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 4f511d6b418ee..bd26b02fe1b92 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -12,6 +12,7 @@ CONF_ENCODING = "encoding" DEFAULT_ENCODING = "utf-8" +NAME = "name" INFO = "info" IDENTITY = "identity" ARP = "arp" diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 47d3fab28ad1d..6c3fb559cba75 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -132,8 +132,9 @@ def update_device_tracker(self): if self.arp_ping and self.devices_arp: if mac not in self.devices_arp: continue + ip_address = self.devices_arp[mac]["address"] interface = self.devices_arp[mac]["interface"] - if not self.do_arp_ping(mac, interface): + if not self.do_arp_ping(ip_address, interface): continue attrs = {} @@ -148,20 +149,19 @@ def update_device_tracker(self): for attr in ATTR_DEVICE_TRACKER: if attr in device and device[attr] is not None: attrs[slugify(attr)] = device[attr] - attrs["scanner_type"] = self.method attrs["scanner_host"] = self.host attrs["scanner_hostname"] = self.hostname self.device_tracker[mac] = attrs - def do_arp_ping(self, mac, interface): + def do_arp_ping(self, ip_address, interface): """Attempt to arp ping MAC address via interface.""" params = { "arp-ping": "yes", "interval": "100ms", "count": 3, "interface": interface, - "address": mac, + "address": ip_address, } cmd = "/ping" data = self.api.command(cmd, params) diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py new file mode 100644 index 0000000000000..cede3a7aad53c --- /dev/null +++ b/homeassistant/components/minio/__init__.py @@ -0,0 +1,265 @@ +"""Minio component.""" +import logging +import os +import threading +from queue import Queue +from typing import List + +import voluptuous as vol + +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +import homeassistant.helpers.config_validation as cv + +from .minio_helper import create_minio_client, MinioEventThread + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "minio" +CONF_HOST = "host" +CONF_PORT = "port" +CONF_ACCESS_KEY = "access_key" +CONF_SECRET_KEY = "secret_key" +CONF_SECURE = "secure" +CONF_LISTEN = "listen" +CONF_LISTEN_BUCKET = "bucket" +CONF_LISTEN_PREFIX = "prefix" +CONF_LISTEN_SUFFIX = "suffix" +CONF_LISTEN_EVENTS = "events" + +ATTR_BUCKET = "bucket" +ATTR_KEY = "key" +ATTR_FILE_PATH = "file_path" + +DEFAULT_LISTEN_PREFIX = "" +DEFAULT_LISTEN_SUFFIX = ".*" +DEFAULT_LISTEN_EVENTS = "s3:ObjectCreated:*" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_ACCESS_KEY): cv.string, + vol.Required(CONF_SECRET_KEY): cv.string, + vol.Required(CONF_SECURE): cv.boolean, + vol.Optional(CONF_LISTEN, default=[]): vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_LISTEN_BUCKET): cv.string, + vol.Optional( + CONF_LISTEN_PREFIX, default=DEFAULT_LISTEN_PREFIX + ): cv.string, + vol.Optional( + CONF_LISTEN_SUFFIX, default=DEFAULT_LISTEN_SUFFIX + ): cv.string, + vol.Optional( + CONF_LISTEN_EVENTS, default=DEFAULT_LISTEN_EVENTS + ): cv.string, + } + ) + ], + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +BUCKET_KEY_SCHEMA = vol.Schema( + {vol.Required(ATTR_BUCKET): cv.template, vol.Required(ATTR_KEY): cv.template} +) + +BUCKET_KEY_FILE_SCHEMA = BUCKET_KEY_SCHEMA.extend( + {vol.Required(ATTR_FILE_PATH): cv.template} +) + + +def setup(hass, config): + """Set up MinioClient and event listeners.""" + conf = config[DOMAIN] + + host = conf[CONF_HOST] + port = conf[CONF_PORT] + access_key = conf[CONF_ACCESS_KEY] + secret_key = conf[CONF_SECRET_KEY] + secure = conf[CONF_SECURE] + + queue_listener = QueueListener(hass) + queue = queue_listener.queue + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, queue_listener.start_handler) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, queue_listener.stop_handler) + + def _setup_listener(listener_conf): + bucket = listener_conf[CONF_LISTEN_BUCKET] + prefix = listener_conf[CONF_LISTEN_PREFIX] + suffix = listener_conf[CONF_LISTEN_SUFFIX] + events = listener_conf[CONF_LISTEN_EVENTS] + + minio_listener = MinioListener( + queue, + get_minio_endpoint(host, port), + access_key, + secret_key, + secure, + bucket, + prefix, + suffix, + events, + ) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, minio_listener.start_handler) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, minio_listener.stop_handler) + + for listen_conf in conf[CONF_LISTEN]: + _setup_listener(listen_conf) + + minio_client = create_minio_client( + get_minio_endpoint(host, port), access_key, secret_key, secure + ) + + def _render_service_value(service, key): + value = service.data[key] + value.hass = hass + return value.async_render() + + def put_file(service): + """Upload file service.""" + bucket = _render_service_value(service, ATTR_BUCKET) + key = _render_service_value(service, ATTR_KEY) + file_path = _render_service_value(service, ATTR_FILE_PATH) + + if not hass.config.is_allowed_path(file_path): + _LOGGER.error("Invalid file_path %s", file_path) + return + + minio_client.fput_object(bucket, key, file_path) + + def get_file(service): + """Download file service.""" + bucket = _render_service_value(service, ATTR_BUCKET) + key = _render_service_value(service, ATTR_KEY) + file_path = _render_service_value(service, ATTR_FILE_PATH) + + if not hass.config.is_allowed_path(file_path): + _LOGGER.error("Invalid file_path %s", file_path) + return + + minio_client.fget_object(bucket, key, file_path) + + def remove_file(service): + """Delete file service.""" + bucket = _render_service_value(service, ATTR_BUCKET) + key = _render_service_value(service, ATTR_KEY) + + minio_client.remove_object(bucket, key) + + hass.services.register(DOMAIN, "put", put_file, schema=BUCKET_KEY_FILE_SCHEMA) + hass.services.register(DOMAIN, "get", get_file, schema=BUCKET_KEY_FILE_SCHEMA) + hass.services.register(DOMAIN, "remove", remove_file, schema=BUCKET_KEY_SCHEMA) + + return True + + +def get_minio_endpoint(host: str, port: int) -> str: + """Create minio endpoint from host and port.""" + return "{}:{}".format(host, port) + + +class QueueListener(threading.Thread): + """Forward events from queue into HASS event bus.""" + + def __init__(self, hass): + """Create queue.""" + super().__init__() + self._hass = hass + self._queue = Queue() + + def run(self): + """Listen to queue events, and forward them to HASS event bus.""" + _LOGGER.info("Running QueueListener") + while True: + event = self._queue.get() + if event is None: + break + + _, file_name = os.path.split(event[ATTR_KEY]) + + _LOGGER.debug( + "Sending event %s, %s, %s", + event["event_name"], + event[ATTR_BUCKET], + event[ATTR_KEY], + ) + self._hass.bus.fire(DOMAIN, {"file_name": file_name, **event}) + + @property + def queue(self): + """Return wrapped queue.""" + return self._queue + + def stop(self): + """Stop run by putting None into queue and join the thread.""" + _LOGGER.info("Stopping QueueListener") + self._queue.put(None) + self.join() + _LOGGER.info("Stopped QueueListener") + + def start_handler(self, _): + """Start handler helper method.""" + self.start() + + def stop_handler(self, _): + """Stop handler helper method.""" + self.stop() + + +class MinioListener: + """MinioEventThread wrapper with helper methods.""" + + def __init__( + self, + queue: Queue, + endpoint: str, + access_key: str, + secret_key: str, + secure: bool, + bucket_name: str, + prefix: str, + suffix: str, + events: List[str], + ): + """Create Listener.""" + self._queue = queue + self._endpoint = endpoint + self._access_key = access_key + self._secret_key = secret_key + self._secure = secure + self._bucket_name = bucket_name + self._prefix = prefix + self._suffix = suffix + self._events = events + self._minio_event_thread = None + + def start_handler(self, _): + """Create and start the event thread.""" + self._minio_event_thread = MinioEventThread( + self._queue, + self._endpoint, + self._access_key, + self._secret_key, + self._secure, + self._bucket_name, + self._prefix, + self._suffix, + self._events, + ) + self._minio_event_thread.start() + + def stop_handler(self, _): + """Issue stop and wait for thread to join.""" + if self._minio_event_thread is not None: + self._minio_event_thread.stop() diff --git a/homeassistant/components/minio/manifest.json b/homeassistant/components/minio/manifest.json new file mode 100644 index 0000000000000..2b2f84836ead3 --- /dev/null +++ b/homeassistant/components/minio/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "minio", + "name": "Minio", + "documentation": "https://www.home-assistant.io/components/minio", + "requirements": [ + "minio==4.0.9" + ], + "dependencies": [], + "codeowners": [ + "@tkislan" + ] +} diff --git a/homeassistant/components/minio/minio_helper.py b/homeassistant/components/minio/minio_helper.py new file mode 100644 index 0000000000000..bd7b15d27d4f5 --- /dev/null +++ b/homeassistant/components/minio/minio_helper.py @@ -0,0 +1,209 @@ +"""Minio helper methods.""" +import time +from collections.abc import Iterable +import json +import logging +import re +import threading +from queue import Queue +from typing import Iterator, List +from urllib.parse import unquote + +from minio import Minio +from urllib3.exceptions import HTTPError + +_LOGGER = logging.getLogger(__name__) + +_METADATA_RE = re.compile("x-amz-meta-(.*)", re.IGNORECASE) + + +def normalize_metadata(metadata: dict) -> dict: + """Normalize object metadata by stripping the prefix.""" + new_metadata = {} + for meta_key, meta_value in metadata.items(): + match = _METADATA_RE.match(meta_key) + if not match: + continue + + new_metadata[match.group(1).lower()] = meta_value + + return new_metadata + + +def create_minio_client( + endpoint: str, access_key: str, secret_key: str, secure: bool +) -> Minio: + """Create Minio client.""" + return Minio(endpoint, access_key, secret_key, secure) + + +def get_minio_notification_response( + minio_client, bucket_name: str, prefix: str, suffix: str, events: List[str] +): + """Start listening to minio events. Copied from minio-py.""" + query = {"prefix": prefix, "suffix": suffix, "events": events} + # pylint: disable=protected-access + return minio_client._url_open( + "GET", bucket_name=bucket_name, query=query, preload_content=False + ) + + +class MinioEventStreamIterator(Iterable): + """Iterator wrapper over notification http response stream.""" + + def __iter__(self) -> Iterator: + """Return self.""" + return self + + def __init__(self, response): + """Init.""" + self._response = response + self._stream = response.stream() + + def __next__(self): + """Get next not empty line.""" + while True: + line = next(self._stream) + if line.strip(): + event = json.loads(line.decode("utf-8")) + if event["Records"] is not None: + return event + + def close(self): + """Close the response.""" + self._response.close() + + +class MinioEventThread(threading.Thread): + """Thread wrapper around minio notification blocking stream.""" + + def __init__( + self, + queue: Queue, + endpoint: str, + access_key: str, + secret_key: str, + secure: bool, + bucket_name: str, + prefix: str, + suffix: str, + events: List[str], + ): + """Copy over all Minio client options.""" + super().__init__() + self._queue = queue + self._endpoint = endpoint + self._access_key = access_key + self._secret_key = secret_key + self._secure = secure + self._bucket_name = bucket_name + self._prefix = prefix + self._suffix = suffix + self._events = events + self._event_stream_it = None + self._should_stop = False + + def __enter__(self): + """Start the thread.""" + self.start() + + def __exit__(self, exc_type, exc_val, exc_tb): + """Stop and join the thread.""" + self.stop() + + def run(self): + """Create MinioClient and run the loop.""" + _LOGGER.info("Running MinioEventThread") + + self._should_stop = False + + minio_client = create_minio_client( + self._endpoint, self._access_key, self._secret_key, self._secure + ) + + while not self._should_stop: + _LOGGER.info("Connecting to minio event stream") + response = None + try: + response = get_minio_notification_response( + minio_client, + self._bucket_name, + self._prefix, + self._suffix, + self._events, + ) + + self._event_stream_it = MinioEventStreamIterator(response) + + self._iterate_event_stream(self._event_stream_it, minio_client) + except json.JSONDecodeError: + if response: + response.close() + except HTTPError as error: + _LOGGER.error("Failed to connect to Minio endpoint: %s", error) + + # Wait before attempting to connect again. + time.sleep(1) + except AttributeError: + # When response is closed, iterator will fail to access + # the underlying socket descriptor. + break + + def _iterate_event_stream(self, event_stream_it, minio_client): + for event in event_stream_it: + for event_name, bucket, key, metadata in iterate_objects(event): + presigned_url = "" + try: + presigned_url = minio_client.presigned_get_object(bucket, key) + # Fail gracefully. If for whatever reason this stops working, + # it shouldn't prevent it from firing events. + # pylint: disable=broad-except + except Exception as error: + _LOGGER.error("Failed to generate presigned url: %s", error) + + queue_entry = { + "event_name": event_name, + "bucket": bucket, + "key": key, + "presigned_url": presigned_url, + "metadata": metadata, + } + _LOGGER.debug("Queue entry, %s", queue_entry) + self._queue.put(queue_entry) + + def stop(self): + """Cancel event stream and join the thread.""" + _LOGGER.debug("Stopping event thread") + self._should_stop = True + if self._event_stream_it is not None: + self._event_stream_it.close() + self._event_stream_it = None + + _LOGGER.debug("Joining event thread") + self.join() + _LOGGER.debug("Event thread joined") + + +def iterate_objects(event): + """ + Iterate over file records of notification event. + + Most of the time it should still be only one record. + """ + records = event.get("Records", []) + + for record in records: + event_name = record.get("eventName") + bucket = record.get("s3", {}).get("bucket", {}).get("name") + key = record.get("s3", {}).get("object", {}).get("key") + metadata = normalize_metadata( + record.get("s3", {}).get("object", {}).get("userMetadata", {}) + ) + + if not bucket or not key: + _LOGGER.warning("Invalid bucket and/or key, %s, %s", bucket, key) + continue + + key = unquote(key) + + yield event_name, bucket, key, metadata diff --git a/homeassistant/components/minio/services.yaml b/homeassistant/components/minio/services.yaml new file mode 100644 index 0000000000000..8fb8a267c3bd3 --- /dev/null +++ b/homeassistant/components/minio/services.yaml @@ -0,0 +1,35 @@ +get: + description: Download file from Minio. + fields: + bucket: + description: Bucket to use. + example: camera-files + key: + description: Object key of the file. + example: front_camera/2018/01/02/snapshot_12512514.jpg + file_path: + description: File path on local filesystem. + example: /data/camera_files/snapshot.jpg + +put: + description: Upload file to Minio. + fields: + bucket: + description: Bucket to use. + example: camera-files + key: + description: Object key of the file. + example: front_camera/2018/01/02/snapshot_12512514.jpg + file_path: + description: File path on local filesystem. + example: /data/camera_files/snapshot.jpg + +remove: + description: Delete file from Minio. + fields: + bucket: + description: Bucket to use. + example: camera-files + key: + description: Object key of the file. + example: front_camera/2018/01/02/snapshot_12512514.jpg diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index 8f5db991c76c9..609ea72cc699c 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -3,8 +3,10 @@ "name": "Netgear lte", "documentation": "https://www.home-assistant.io/components/netgear_lte", "requirements": [ - "eternalegypt==0.0.9" + "eternalegypt==0.0.10" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@amelchio" + ] } diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 409b4d382083e..38b7018af6c6c 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -24,14 +24,12 @@ DATA_LEAF = "nissan_leaf_data" DATA_BATTERY = "battery" -DATA_LOCATION = "location" DATA_CHARGING = "charging" DATA_PLUGGED_IN = "plugged_in" DATA_CLIMATE = "climate" DATA_RANGE_AC = "range_ac_on" DATA_RANGE_AC_OFF = "range_ac_off" -CONF_NCONNECT = "nissan_connect" CONF_INTERVAL = "update_interval" CONF_CHARGING_INTERVAL = "update_interval_charging" CONF_CLIMATE_INTERVAL = "update_interval_climate" @@ -61,7 +59,6 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_REGION): vol.In(CONF_VALID_REGIONS), - vol.Optional(CONF_NCONNECT, default=True): cv.boolean, vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): ( vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)) ), @@ -84,7 +81,7 @@ extra=vol.ALLOW_EXTRA, ) -LEAF_COMPONENTS = ["sensor", "switch", "binary_sensor", "device_tracker"] +LEAF_COMPONENTS = ["sensor", "switch", "binary_sensor"] SIGNAL_UPDATE_LEAF = "nissan_leaf_update" @@ -177,8 +174,7 @@ def setup_leaf(car_config): hass.data[DATA_LEAF][leaf.vin] = data_store for component in LEAF_COMPONENTS: - if component != "device_tracker" or car_config[CONF_NCONNECT]: - load_platform(hass, component, DOMAIN, {}, car_config) + load_platform(hass, component, DOMAIN, {}, car_config) async_track_point_in_utc_time( hass, data_store.async_update_data, utcnow() + INITIAL_UPDATE @@ -209,24 +205,20 @@ def __init__(self, hass, leaf, car_config): self.hass = hass self.leaf = leaf self.car_config = car_config - self.nissan_connect = car_config[CONF_NCONNECT] self.force_miles = car_config[CONF_FORCE_MILES] self.data = {} self.data[DATA_CLIMATE] = False self.data[DATA_BATTERY] = 0 self.data[DATA_CHARGING] = False - self.data[DATA_LOCATION] = False self.data[DATA_RANGE_AC] = 0 self.data[DATA_RANGE_AC_OFF] = 0 self.data[DATA_PLUGGED_IN] = False self.next_update = None self.last_check = None self.request_in_progress = False - # Timestamp of last successful response from battery, - # climate or location. + # Timestamp of last successful response from battery or climate. self.last_battery_response = None self.last_climate_response = None - self.last_location_response = None self._remove_listener = None async def async_update_data(self, now): @@ -334,20 +326,6 @@ async def async_refresh_data(self, now): except CarwingsError: _LOGGER.error("Error fetching climate info") - if self.nissan_connect: - try: - location_response = await self.async_get_location() - - if location_response is None: - _LOGGER.debug("Empty Location Response Received") - self.data[DATA_LOCATION] = None - else: - _LOGGER.debug("Location Response: %s", location_response.__dict__) - self.data[DATA_LOCATION] = location_response - self.last_location_response = utcnow() - except CarwingsError: - _LOGGER.error("Error fetching location info") - self.request_in_progress = False async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) @@ -364,19 +342,6 @@ async def async_get_battery(self): from pycarwings2 import CarwingsError try: - # First, check nissan servers for the latest data - start_server_info = await self.hass.async_add_executor_job( - self.leaf.get_latest_battery_status - ) - - # Store the date from the nissan servers - start_date = self._extract_start_date(start_server_info) - if start_date is None: - _LOGGER.info("No start date from servers. Aborting") - return None - - _LOGGER.debug("Start server date=%s", start_date) - # Request battery update from the car _LOGGER.debug("Requesting battery update, %s", self.leaf.vin) request = await self.hass.async_add_executor_job(self.leaf.request_update) @@ -393,21 +358,30 @@ async def async_get_battery(self): ) await asyncio.sleep(PYCARWINGS2_SLEEP) - # Note leaf.get_status_from_update is always returning 0, so - # don't try to use it anymore. - server_info = await self.hass.async_add_executor_job( - self.leaf.get_latest_battery_status + # We don't use the response from get_status_from_update + # apart from knowing that the car has responded saying it + # has given the latest battery status to Nissan. + check_result_info = await self.hass.async_add_executor_job( + self.leaf.get_status_from_update, request ) - latest_date = self._extract_start_date(server_info) - _LOGGER.debug("Latest server date=%s", latest_date) - if latest_date is not None and latest_date != start_date: + if check_result_info is not None: + # Get the latest battery status from Nissan servers. + # This has the SOC in it. + server_info = await self.hass.async_add_executor_job( + self.leaf.get_latest_battery_status + ) return server_info _LOGGER.debug( "%s attempts exceeded return latest data from server", MAX_RESPONSE_ATTEMPTS, ) + # Get the latest data from the nissan servers, even though + # it may be out of date, it's better than nothing. + server_info = await self.hass.async_add_executor_job( + self.leaf.get_latest_battery_status + ) return server_info except CarwingsError: _LOGGER.error("An error occurred getting battery status.") @@ -465,29 +439,6 @@ async def async_set_climate(self, toggle): _LOGGER.debug("Climate result not returned by Nissan servers") return False - async def async_get_location(self): - """Get location from Nissan servers.""" - request = await self.hass.async_add_executor_job(self.leaf.request_location) - for attempt in range(MAX_RESPONSE_ATTEMPTS): - if attempt > 0: - _LOGGER.debug( - "Location data not in yet. (%s) (%s). " "Waiting %s seconds", - self.leaf.vin, - attempt, - PYCARWINGS2_SLEEP, - ) - await asyncio.sleep(PYCARWINGS2_SLEEP) - - location_status = await self.hass.async_add_executor_job( - self.leaf.get_status_from_location, request - ) - - if location_status is not None: - _LOGGER.debug("Location_status=%s", location_status.__dict__) - break - - return location_status - class LeafEntity(Entity): """Base class for Nissan Leaf entity.""" diff --git a/homeassistant/components/nissan_leaf/device_tracker.py b/homeassistant/components/nissan_leaf/device_tracker.py deleted file mode 100644 index 11d18ee5a8e7c..0000000000000 --- a/homeassistant/components/nissan_leaf/device_tracker.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Support for tracking a Nissan Leaf.""" -import logging - -from homeassistant.helpers.dispatcher import dispatcher_connect -from homeassistant.util import slugify - -from . import DATA_LEAF, DATA_LOCATION, SIGNAL_UPDATE_LEAF - -_LOGGER = logging.getLogger(__name__) - -ICON_CAR = "mdi:car" - - -def setup_scanner(hass, config, see, discovery_info=None): - """Set up the Nissan Leaf tracker.""" - if discovery_info is None: - return False - - def see_vehicle(): - """Handle the reporting of the vehicle position.""" - for vin, datastore in hass.data[DATA_LEAF].items(): - host_name = datastore.leaf.nickname - dev_id = "nissan_leaf_{}".format(slugify(host_name)) - if not datastore.data[DATA_LOCATION]: - _LOGGER.debug("No position found for vehicle %s", vin) - return - _LOGGER.debug( - "Updating device_tracker for %s with position %s", - datastore.leaf.nickname, - datastore.data[DATA_LOCATION].__dict__, - ) - attrs = {"updated_on": datastore.last_location_response} - see( - dev_id=dev_id, - host_name=host_name, - gps=( - datastore.data[DATA_LOCATION].latitude, - datastore.data[DATA_LOCATION].longitude, - ), - attributes=attrs, - icon=ICON_CAR, - ) - - dispatcher_connect(hass, SIGNAL_UPDATE_LEAF, see_vehicle) - - return True diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json index ab94c01b7c127..70aaa112414be 100644 --- a/homeassistant/components/nissan_leaf/manifest.json +++ b/homeassistant/components/nissan_leaf/manifest.json @@ -3,7 +3,7 @@ "name": "Nissan leaf", "documentation": "https://www.home-assistant.io/components/nissan_leaf", "requirements": [ - "pycarwings2==2.8" + "pycarwings2==2.9" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py new file mode 100644 index 0000000000000..dde2f6dee11c5 --- /dev/null +++ b/homeassistant/components/nws/__init__.py @@ -0,0 +1 @@ +"""NWS Integration.""" diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json new file mode 100644 index 0000000000000..b0e5fdb208844 --- /dev/null +++ b/homeassistant/components/nws/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "nws", + "name": "National Weather Service", + "documentation": "https://www.home-assistant.io/components/nws", + "dependencies": [], + "codeowners": ["@MatthewFlamm"], + "requirements": ["pynws==0.7.4"] +} diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py new file mode 100644 index 0000000000000..23cf84411a378 --- /dev/null +++ b/homeassistant/components/nws/weather.py @@ -0,0 +1,378 @@ +"""Support for NWS weather service.""" +from collections import OrderedDict +from datetime import timedelta +from json import JSONDecodeError +import logging + +import aiohttp +from pynws import SimpleNWS +import voluptuous as vol + +from homeassistant.components.weather import ( + WeatherEntity, + PLATFORM_SCHEMA, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_SPEED, + ATTR_FORECAST_WIND_BEARING, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_NAME, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PRESSURE_HPA, + PRESSURE_PA, + PRESSURE_INHG, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import config_validation as cv +from homeassistant.util import Throttle +from homeassistant.util.distance import convert as convert_distance +from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.temperature import convert as convert_temperature + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data from National Weather Service/NOAA" + +SCAN_INTERVAL = timedelta(minutes=15) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +CONF_STATION = "station" + +ATTR_FORECAST_DETAIL_DESCRIPTION = "detailed_description" +ATTR_FORECAST_PRECIP_PROB = "precipitation_probability" +ATTR_FORECAST_DAYTIME = "daytime" + +# Ordered so that a single condition can be chosen from multiple weather codes. +# Catalog of NWS icon weather codes listed at: +# https://api.weather.gov/icons +CONDITION_CLASSES = OrderedDict( + [ + ( + "exceptional", + [ + "Tornado", + "Hurricane conditions", + "Tropical storm conditions", + "Dust", + "Smoke", + "Haze", + "Hot", + "Cold", + ], + ), + ("snowy", ["Snow", "Sleet", "Blizzard"]), + ( + "snowy-rainy", + [ + "Rain/snow", + "Rain/sleet", + "Freezing rain/snow", + "Freezing rain", + "Rain/freezing rain", + ], + ), + ("hail", []), + ( + "lightning-rainy", + [ + "Thunderstorm (high cloud cover)", + "Thunderstorm (medium cloud cover)", + "Thunderstorm (low cloud cover)", + ], + ), + ("lightning", []), + ("pouring", []), + ( + "rainy", + [ + "Rain", + "Rain showers (high cloud cover)", + "Rain showers (low cloud cover)", + ], + ), + ("windy-variant", ["Mostly cloudy and windy", "Overcast and windy"]), + ( + "windy", + [ + "Fair/clear and windy", + "A few clouds and windy", + "Partly cloudy and windy", + ], + ), + ("fog", ["Fog/mist"]), + ("clear", ["Fair/clear"]), # sunny and clear-night + ("cloudy", ["Mostly cloudy", "Overcast"]), + ("partlycloudy", ["A few clouds", "Partly cloudy"]), + ] +) + +ERRORS = (aiohttp.ClientError, JSONDecodeError) + +FORECAST_MODE = ["daynight", "hourly"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Inclusive( + CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" + ): cv.longitude, + vol.Optional(CONF_MODE, default="daynight"): vol.In(FORECAST_MODE), + vol.Optional(CONF_STATION): cv.string, + vol.Required(CONF_API_KEY): cv.string, + } +) + + +def convert_condition(time, weather): + """ + Convert NWS codes to HA condition. + + Choose first condition in CONDITION_CLASSES that exists in weather code. + If no match is found, return first condition from NWS + """ + conditions = [w[0] for w in weather] + prec_probs = [w[1] or 0 for w in weather] + + # Choose condition with highest priority. + cond = next( + ( + key + for key, value in CONDITION_CLASSES.items() + if any(condition in value for condition in conditions) + ), + conditions[0], + ) + + if cond == "clear": + if time == "day": + return "sunny", max(prec_probs) + if time == "night": + return "clear-night", max(prec_probs) + return cond, max(prec_probs) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the NWS weather platform.""" + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + station = config.get(CONF_STATION) + api_key = config[CONF_API_KEY] + mode = config[CONF_MODE] + + websession = async_get_clientsession(hass) + # ID request as being from HA, pynws prepends the api_key in addition + api_key_ha = f"{api_key} homeassistant" + nws = SimpleNWS(latitude, longitude, api_key_ha, mode, websession) + + _LOGGER.debug("Setting up station: %s", station) + try: + await nws.set_station(station) + except ERRORS as status: + _LOGGER.error( + "Error getting station list for %s: %s", (latitude, longitude), status + ) + raise PlatformNotReady + + _LOGGER.debug("Station list: %s", nws.stations) + _LOGGER.debug( + "Initialized for coordinates %s, %s -> station %s", + latitude, + longitude, + nws.station, + ) + + async_add_entities([NWSWeather(nws, mode, hass.config.units, config)], True) + + +class NWSWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, nws, mode, units, config): + """Initialise the platform with a data instance and station name.""" + self.nws = nws + self.station_name = config.get(CONF_NAME, self.nws.station) + self.is_metric = units.is_metric + self.mode = mode + + self.observation = None + self._forecast = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update Condition.""" + _LOGGER.debug("Updating station observations %s", self.nws.station) + try: + await self.nws.update_observation() + except ERRORS as status: + _LOGGER.error( + "Error updating observation from station %s: %s", + self.nws.station, + status, + ) + else: + self.observation = self.nws.observation + _LOGGER.debug("Updating forecast") + try: + await self.nws.update_forecast() + except ERRORS as status: + _LOGGER.error( + "Error updating forecast from station %s: %s", self.nws.station, status + ) + return + self._forecast = self.nws.forecast + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def name(self): + """Return the name of the station.""" + return self.station_name + + @property + def temperature(self): + """Return the current temperature.""" + temp_c = None + if self.observation: + temp_c = self.observation.get("temperature") + if temp_c: + return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) + return None + + @property + def pressure(self): + """Return the current pressure.""" + pressure_pa = None + if self.observation: + pressure_pa = self.observation.get("seaLevelPressure") + if pressure_pa is None: + return None + if self.is_metric: + pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA) + pressure = round(pressure) + else: + pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_INHG) + pressure = round(pressure, 2) + return pressure + + @property + def humidity(self): + """Return the name of the sensor.""" + humidity = None + if self.observation: + humidity = self.observation.get("relativeHumidity") + return humidity + + @property + def wind_speed(self): + """Return the current windspeed.""" + wind_m_s = None + if self.observation: + wind_m_s = self.observation.get("windSpeed") + if wind_m_s is None: + return None + wind_m_hr = wind_m_s * 3600 + + if self.is_metric: + wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_KILOMETERS) + else: + wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_MILES) + return round(wind) + + @property + def wind_bearing(self): + """Return the current wind bearing (degrees).""" + wind_bearing = None + if self.observation: + wind_bearing = self.observation.get("windDirection") + return wind_bearing + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def condition(self): + """Return current condition.""" + weather = None + if self.observation: + weather = self.observation.get("iconWeather") + time = self.observation.get("iconTime") + + if weather: + cond, _ = convert_condition(time, weather) + return cond + return None + + @property + def visibility(self): + """Return visibility.""" + vis_m = None + if self.observation: + vis_m = self.observation.get("visibility") + if vis_m is None: + return None + + if self.is_metric: + vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_KILOMETERS) + else: + vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_MILES) + return round(vis, 0) + + @property + def forecast(self): + """Return forecast.""" + if self._forecast is None: + return None + forecast = [] + for forecast_entry in self._forecast: + data = { + ATTR_FORECAST_DETAIL_DESCRIPTION: forecast_entry.get( + "detailedForecast" + ), + ATTR_FORECAST_TEMP: forecast_entry.get("temperature"), + ATTR_FORECAST_TIME: forecast_entry.get("startTime"), + } + + if self.mode == "daynight": + data[ATTR_FORECAST_DAYTIME] = forecast_entry.get("isDaytime") + time = forecast_entry.get("iconTime") + weather = forecast_entry.get("iconWeather") + if time and weather: + cond, precip = convert_condition(time, weather) + else: + cond, precip = None, None + data[ATTR_FORECAST_CONDITION] = cond + data[ATTR_FORECAST_PRECIP_PROB] = precip + + data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing") + wind_speed = forecast_entry.get("windSpeedAvg") + if wind_speed: + if self.is_metric: + data[ATTR_FORECAST_WIND_SPEED] = round( + convert_distance(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) + ) + else: + data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed) + else: + data[ATTR_FORECAST_WIND_SPEED] = None + forecast.append(data) + return forecast diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 67f5c93dbc86a..ff85d182a2240 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -6,19 +6,20 @@ from homeassistant.components.cover import ( CoverDevice, + DEVICE_CLASS_GARAGE, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, ) from homeassistant.const import ( - CONF_DEVICE, CONF_NAME, - STATE_UNKNOWN, STATE_CLOSED, STATE_OPEN, CONF_COVERS, CONF_HOST, CONF_PORT, + STATE_CLOSING, + STATE_OPENING, ) import homeassistant.helpers.config_validation as cv @@ -28,17 +29,11 @@ ATTR_DOOR_STATE = "door_state" ATTR_SIGNAL_STRENGTH = "wifi_signal" -CONF_DEVICE_ID = "device_id" CONF_DEVICE_KEY = "device_key" DEFAULT_NAME = "OpenGarage" DEFAULT_PORT = 80 -STATE_CLOSING = "closing" -STATE_OFFLINE = "offline" -STATE_OPENING = "opening" -STATE_STOPPED = "stopped" - STATES_MAP = {0: STATE_CLOSED, 1: STATE_OPEN} COVER_SCHEMA = vol.Schema( @@ -60,16 +55,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): covers = [] devices = config.get(CONF_COVERS) - for device_id, device_config in devices.items(): + for device_config in devices.values(): args = { CONF_NAME: device_config.get(CONF_NAME), CONF_HOST: device_config.get(CONF_HOST), CONF_PORT: device_config.get(CONF_PORT), - CONF_DEVICE_ID: device_config.get(CONF_DEVICE, device_id), CONF_DEVICE_KEY: device_config.get(CONF_DEVICE_KEY), } - covers.append(OpenGarageCover(hass, args)) + covers.append(OpenGarageCover(args)) add_entities(covers, True) @@ -77,17 +71,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class OpenGarageCover(CoverDevice): """Representation of a OpenGarage cover.""" - def __init__(self, hass, args): + def __init__(self, args): """Initialize the cover.""" self.opengarage_url = "http://{}:{}".format(args[CONF_HOST], args[CONF_PORT]) - self.hass = hass self._name = args[CONF_NAME] - self.device_id = args["device_id"] self._device_key = args[CONF_DEVICE_KEY] self._state = None self._state_before_move = None - self.dist = None - self.signal = None + self._device_state_attributes = {} self._available = True @property @@ -103,93 +94,91 @@ def available(self): @property def device_state_attributes(self): """Return the device state attributes.""" - data = {} - - if self.signal is not None: - data[ATTR_SIGNAL_STRENGTH] = self.signal - - if self.dist is not None: - data[ATTR_DISTANCE_SENSOR] = self.dist - - if self._state is not None: - data[ATTR_DOOR_STATE] = self._state - - return data + return self._device_state_attributes @property def is_closed(self): """Return if the cover is closed.""" - if self._state in [STATE_UNKNOWN, STATE_OFFLINE]: + if self._state is None: return None return self._state in [STATE_CLOSED, STATE_OPENING] def close_cover(self, **kwargs): """Close the cover.""" - if self._state not in [STATE_CLOSED, STATE_CLOSING]: - self._state_before_move = self._state - self._state = STATE_CLOSING - self._push_button() + if self._state in [STATE_CLOSED, STATE_CLOSING]: + return + self._state_before_move = self._state + self._state = STATE_CLOSING + self._push_button() def open_cover(self, **kwargs): """Open the cover.""" - if self._state not in [STATE_OPEN, STATE_OPENING]: - self._state_before_move = self._state - self._state = STATE_OPENING - self._push_button() + if self._state in [STATE_OPEN, STATE_OPENING]: + return + self._state_before_move = self._state + self._state = STATE_OPENING + self._push_button() def update(self): """Get updated status from API.""" try: - status = self._get_status() - if self._name is None: - if status["name"] is not None: - self._name = status["name"] - state = STATES_MAP.get(status.get("door"), STATE_UNKNOWN) - if self._state_before_move is not None: - if self._state_before_move != state: - self._state = state - self._state_before_move = None - else: - self._state = state - - _LOGGER.debug("%s status: %s", self._name, self._state) - self.signal = status.get("rssi") - self.dist = status.get("dist") - self._available = True + status = requests.get( + "{}/jc".format(self.opengarage_url), timeout=10 + ).json() except requests.exceptions.RequestException as ex: _LOGGER.error( "Unable to connect to OpenGarage device: %(reason)s", dict(reason=ex) ) - self._state = STATE_OFFLINE + self._available = False + return + + if self._name is None and status["name"] is not None: + self._name = status["name"] + state = STATES_MAP.get(status.get("door")) + if self._state_before_move is not None: + if self._state_before_move != state: + self._state = state + self._state_before_move = None + else: + self._state = state + + _LOGGER.debug("%s status: %s", self._name, self._state) + if status.get("rssi") is not None: + self._device_state_attributes[ATTR_SIGNAL_STRENGTH] = status.get("rssi") + if status.get("dist") is not None: + self._device_state_attributes[ATTR_DISTANCE_SENSOR] = status.get("dist") + if self._state is not None: + self._device_state_attributes[ATTR_DOOR_STATE] = self._state - def _get_status(self): - """Get latest status.""" - url = "{}/jc".format(self.opengarage_url) - ret = requests.get(url, timeout=10) - return ret.json() + self._available = True def _push_button(self): """Send commands to API.""" - url = "{}/cc?dkey={}&click=1".format(self.opengarage_url, self._device_key) + result = -1 try: - response = requests.get(url, timeout=10).json() - if response["result"] == 2: - _LOGGER.error( - "Unable to control %s: Device key is incorrect", self._name - ) - self._state = self._state_before_move - self._state_before_move = None + result = requests.get( + "{}/cc?dkey={}&click=1".format(self.opengarage_url, self._device_key), + timeout=10, + ).json()["result"] except requests.exceptions.RequestException as ex: _LOGGER.error( "Unable to connect to OpenGarage device: %(reason)s", dict(reason=ex) ) - self._state = self._state_before_move - self._state_before_move = None + if result == 1: + return + + if result == 2: + _LOGGER.error("Unable to control %s: Device key is incorrect", self._name) + elif result > 2: + _LOGGER.error("Unable to control %s: Error code %s", self._name, result) + + self._state = self._state_before_move + self._state_before_move = None @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return "garage" + return DEVICE_CLASS_GARAGE @property def supported_features(self): diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 7dc7e164788e9..4a0db111b7d51 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -17,7 +17,7 @@ EVENT_MAP = { "off": STATE_ALARM_DISARMED, - "alarm_silenced": STATE_ALARM_ARMED_AWAY, + "alarm_silenced": STATE_ALARM_DISARMED, "alarm_grace_period_expired": STATE_ALARM_TRIGGERED, } @@ -63,11 +63,14 @@ def _webhook_event(self, data, webhook): """Process new event from the webhook.""" _type = data.get("event", {}).get("type") _device_id = data.get("event", {}).get("device_id") - if _device_id not in self._home["devices"] or _type not in EVENT_MAP: + _changed_by = data.get("event", {}).get("user_id") + if ( + _device_id not in self._home["devices"] and _type not in EVENT_MAP + ) and _type != "alarm_silenced": # alarm_silenced does not have device_id return _LOGGER.debug("Received webhook: %s", _type) - self._home["alarm_status"] = EVENT_MAP[_type] - self._changed_by = _device_id + self._home["alarm_status"] = _type + self._changed_by = _changed_by self.async_schedule_update_ha_state() @property diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 788da6a8d6432..af0865bc685a2 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -9,8 +9,10 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service import async_set_service_schema from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename +from homeassistant.util.yaml.loader import load_yaml import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -90,15 +92,28 @@ def python_script_service_handler(call): continue hass.services.remove(DOMAIN, existing_service) + # Load user-provided service descriptions from python_scripts/services.yaml + services_yaml = os.path.join(path, "services.yaml") + if os.path.exists(services_yaml): + services_dict = load_yaml(services_yaml) + else: + services_dict = {} + for fil in glob.iglob(os.path.join(path, "*.py")): name = os.path.splitext(os.path.basename(fil))[0] hass.services.register(DOMAIN, name, python_script_service_handler) + service_desc = { + "description": services_dict.get(name, {}).get("description", ""), + "fields": services_dict.get(name, {}).get("fields", {}), + } + async_set_service_schema(hass, DOMAIN, name, service_desc) + @bind_hass def execute_script(hass, name, data=None): """Execute a script.""" - filename = "{}.py".format(name) + filename = f"{name}.py" with open(hass.config.path(FOLDER, sanitize_filename(filename))) as fil: source = fil.read() execute(hass, filename, source, data) @@ -151,9 +166,7 @@ def protected_getattr(obj, name, default=None): or isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME ): - raise ScriptError( - "Not allowed to access {}.{}".format(obj.__class__.__name__, name) - ) + raise ScriptError(f"Not allowed to access {obj.__class__.__name__}.{name}") return getattr(obj, name, default) @@ -173,7 +186,7 @@ def protected_getattr(obj, name, default=None): "_iter_unpack_sequence_": guarded_iter_unpack_sequence, "_unpack_sequence_": guarded_unpack_sequence, } - logger = logging.getLogger("{}.{}".format(__name__, filename)) + logger = logging.getLogger(f"{__name__}.{filename}") local = {"hass": hass, "data": data or {}, "logger": logger} try: diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 3ae1c8bf58514..1d8ed8e37b169 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -29,13 +29,12 @@ def setup(hass, config): from pyrainbird import RainbirdController - controller = RainbirdController() - controller.setConfig(server, password) + controller = RainbirdController(server, password) _LOGGER.debug("Rain Bird Controller set to: %s", server) initial_status = controller.currentIrrigation() - if initial_status == -1: + if initial_status and initial_status["type"] != "CurrentStationsActiveResponse": _LOGGER.error("Error getting state. Possible configuration issues") return False diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 24113d6253427..584ea22afe23e 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -3,7 +3,7 @@ "name": "Rainbird", "documentation": "https://www.home-assistant.io/components/rainbird", "requirements": [ - "pyrainbird==0.1.6" + "pyrainbird==0.2.1" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index d59ea3b0fecce..2d4549a21d5db 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -56,7 +56,11 @@ def update(self): """Get the latest data and updates the states.""" _LOGGER.debug("Updating sensor: %s", self._name) if self._sensor_type == "rainsensor": - self._state = self._controller.currentRainSensorState() + result = self._controller.currentRainSensorState() + if result and result["type"] == "CurrentRainSensorStateResponse": + self._state = result["sensorState"] + else: + self._state = None @property def name(self): diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 94b37c52fb7ac..a1b82bc1af745 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -70,15 +70,23 @@ def name(self): def turn_on(self, **kwargs): """Turn the switch on.""" - self._rainbird.startIrrigation(int(self._zone), int(self._duration)) + response = self._rainbird.startIrrigation(int(self._zone), int(self._duration)) + if response and response["type"] == "AcknowledgeResponse": + self._state = True def turn_off(self, **kwargs): """Turn the switch off.""" - self._rainbird.stopIrrigation() + response = self._rainbird.stopIrrigation() + if response and response["type"] == "AcknowledgeResponse": + self._state = False def get_device_status(self): """Get the status of the switch from Rain Bird Controller.""" - return self._rainbird.currentIrrigation() == self._zone + response = self._rainbird.currentIrrigation() + if response is None: + return None + if isinstance(response, dict) and "sprinklers" in response: + return response["sprinklers"][self._zone] def update(self): """Update switch status.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index aee993fa10489..3de0430d8f389 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -107,7 +107,7 @@ def _drop_index(engine, table_name, index_name): # Engines like DB2/Oracle try: - engine.execute(text("DROP INDEX {index}".format(index=index_name))) + engine.execute(text(f"DROP INDEX {index_name}")) except SQLAlchemyError: pass else: @@ -170,7 +170,7 @@ def _add_columns(engine, table_name, columns_def): table_name, ) - columns_def = ["ADD {}".format(col_def) for col_def in columns_def] + columns_def = [f"ADD {col_def}" for col_def in columns_def] try: engine.execute( @@ -265,9 +265,7 @@ def _apply_update(engine, new_version, old_version): # 'context_parent_id CHARACTER(36)', # ]) else: - raise ValueError( - "No schema migration defined for version {}".format(new_version) - ) + raise ValueError(f"No schema migration defined for version {new_version}") def _inspect_schema_version(engine, session): diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index f5870e4249017..bf5e90e21f144 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -10,6 +10,7 @@ CONF_HOST, CONF_API_KEY, CONF_NAME, + CONF_PATH, CONF_PORT, CONF_SENSORS, CONF_SSL, @@ -69,6 +70,7 @@ { vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PATH): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SENSORS): vol.All( @@ -102,17 +104,20 @@ async def async_configure_sabnzbd( host = config[CONF_HOST] port = config[CONF_PORT] + web_root = config.get(CONF_PATH) uri_scheme = "https" if use_ssl else "http" base_url = BASE_URL_FORMAT.format(uri_scheme, host, port) if api_key is None: conf = await hass.async_add_job(load_json, hass.config.path(CONFIG_FILE)) api_key = conf.get(base_url, {}).get(CONF_API_KEY, "") - sab_api = SabnzbdApi(base_url, api_key, session=async_get_clientsession(hass)) + sab_api = SabnzbdApi( + base_url, api_key, web_root=web_root, session=async_get_clientsession(hass) + ) if await async_check_sabnzbd(sab_api): async_setup_sabnzbd(hass, sab_api, config, name) else: - async_request_configuration(hass, config, base_url) + async_request_configuration(hass, config, base_url, web_root) async def async_setup(hass, config): @@ -181,7 +186,7 @@ async def async_update_sabnzbd(now): @callback -def async_request_configuration(hass, config, host): +def async_request_configuration(hass, config, host, web_root): """Request configuration steps from the user.""" from pysabnzbd import SabnzbdApi @@ -197,7 +202,9 @@ def async_request_configuration(hass, config, host): async def async_configuration_callback(data): """Handle configuration changes.""" api_key = data.get(CONF_API_KEY) - sab_api = SabnzbdApi(host, api_key, session=async_get_clientsession(hass)) + sab_api = SabnzbdApi( + host, api_key, web_root=web_root, session=async_get_clientsession(hass) + ) if not await async_check_sabnzbd(sab_api): return diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 44e11d83afa14..5a3223a8508f3 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_component import EntityComponent import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ENTITY_SERVICE_SCHEMA +from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.script import Script @@ -31,6 +32,9 @@ ATTR_LAST_TRIGGERED = "last_triggered" ATTR_VARIABLES = "variables" +CONF_DESCRIPTION = "description" +CONF_EXAMPLE = "example" +CONF_FIELDS = "fields" CONF_SEQUENCE = "sequence" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -38,7 +42,17 @@ GROUP_NAME_ALL_SCRIPTS = "all scripts" SCRIPT_ENTRY_SCHEMA = vol.Schema( - {CONF_ALIAS: cv.string, vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA} + { + CONF_ALIAS: cv.string, + vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_DESCRIPTION, default=""): cv.string, + vol.Optional(CONF_FIELDS, default={}): { + cv.string: { + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_EXAMPLE): cv.string, + } + }, + } ) CONFIG_SCHEMA = vol.Schema( @@ -137,6 +151,13 @@ async def service_handler(service): DOMAIN, object_id, service_handler, schema=SCRIPT_SERVICE_SCHEMA ) + # Register the service description + service_desc = { + CONF_DESCRIPTION: cfg[CONF_DESCRIPTION], + CONF_FIELDS: cfg[CONF_FIELDS], + } + async_set_service_schema(hass, DOMAIN, object_id, service_desc) + await component.async_add_entities(scripts) @@ -188,7 +209,7 @@ async def async_turn_on(self, **kwargs): await self.script.async_run(kwargs.get(ATTR_VARIABLES), context) except Exception as err: # pylint: disable=broad-except self.script.async_log_exception( - _LOGGER, "Error executing script {}".format(self.entity_id), err + _LOGGER, f"Error executing script {self.entity_id}", err ) raise err diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 028121a966354..d44a1c7760aae 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -170,7 +170,7 @@ async def async_update(self): """Update alarm status.""" event_data = self._simplisafe.last_event_data[self._system.system_id] - if event_data["pinName"]: + if event_data.get("pinName"): self._changed_by = event_data["pinName"] if self._system.state == SystemStates.error: diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 8795029bff2e6..ea3a33d55ff81 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,7 +3,7 @@ "name": "Sma", "documentation": "https://www.home-assistant.io/components/sma", "requirements": [ - "pysma==0.3.2" + "pysma==0.3.4" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 8e6b94ef5f8b4..b2692a37059be 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -19,6 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval +from homeassistant.const import MINOR_VERSION, MAJOR_VERSION _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,7 @@ CONF_UNIT = "unit" GROUPS = ["user", "installer"] +OLD_CONFIG_DEPRECATED = MAJOR_VERSION > 0 or MINOR_VERSION > 98 def _check_sensor_schema(conf): @@ -41,16 +43,39 @@ def _check_sensor_schema(conf): except (ImportError, AttributeError): return conf - for name in conf[CONF_CUSTOM]: - valid.append(name) + customs = list(conf[CONF_CUSTOM].keys()) + + if isinstance(conf[CONF_SENSORS], dict): + msg = '"sensors" should be a simple list from 0.99' + if OLD_CONFIG_DEPRECATED: + raise vol.Invalid(msg) + _LOGGER.warning(msg) + valid.extend(customs) + + for sname, attrs in conf[CONF_SENSORS].items(): + if sname not in valid: + raise vol.Invalid("{} does not exist".format(sname)) + if attrs: + _LOGGER.warning( + "Attributes on sensors will be deprecated in 0.99. Start using only individual sensors: %s: %s", + sname, + ", ".join(attrs), + ) + for attr in attrs: + if attr in valid: + continue + raise vol.Invalid("{} does not exist [{}]".format(attr, sname)) + return conf - for sname, attrs in conf[CONF_SENSORS].items(): - if sname not in valid: - raise vol.Invalid("{} does not exist".format(sname)) - for attr in attrs: - if attr in valid: - continue - raise vol.Invalid("{} does not exist [{}]".format(attr, sname)) + # Sensors is a list (only option from from 0.99) + for sensor in conf[CONF_SENSORS]: + if sensor in customs: + _LOGGER.warning( + "All custom sensors will be added automatically, no need to include them in sensors: %s", + sensor, + ) + elif sensor not in valid: + raise vol.Invalid("{} does not exist".format(sensor)) return conf @@ -59,7 +84,7 @@ def _check_sensor_schema(conf): vol.Required(CONF_KEY): vol.All(cv.string, vol.Length(min=13, max=15)), vol.Required(CONF_UNIT): cv.string, vol.Optional(CONF_FACTOR, default=1): vol.Coerce(float), - vol.Optional(CONF_PATH): vol.All(cv.ensure_list, [str]), + vol.Optional(CONF_PATH): vol.All(cv.ensure_list, [cv.string]), } ) @@ -71,8 +96,9 @@ def _check_sensor_schema(conf): vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), - vol.Optional(CONF_SENSORS, default={}): cv.schema_with_slug_keys( - cv.ensure_list + vol.Optional(CONF_SENSORS, default=[]): vol.Any( + cv.schema_with_slug_keys(cv.ensure_list), # will be deprecated + vol.All(cv.ensure_list, [str]), ), vol.Optional(CONF_CUSTOM, default={}): cv.schema_with_slug_keys( CUSTOM_SCHEMA @@ -104,20 +130,29 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= # Use all sensors by default config_sensors = config[CONF_SENSORS] - if not config_sensors: - config_sensors = {s.name: [] for s in sensor_def} - - # Prepare all HASS sensor entities hass_sensors = [] used_sensors = [] - for name, attr in config_sensors.items(): - sub_sensors = [sensor_def[s] for s in attr] - hass_sensors.append(SMAsensor(sensor_def[name], sub_sensors)) - used_sensors.append(name) - used_sensors.extend(attr) + + if isinstance(config_sensors, dict): # will be remove from 0.99 + if not config_sensors: # Use all sensors by default + config_sensors = {s.name: [] for s in sensor_def} + + # Prepare all HASS sensor entities + for name, attr in config_sensors.items(): + sub_sensors = [sensor_def[s] for s in attr] + hass_sensors.append(SMAsensor(sensor_def[name], sub_sensors)) + used_sensors.append(name) + used_sensors.extend(attr) + used_sensors = [sensor_def[s] for s in set(used_sensors)] + + if isinstance(config_sensors, list): + if not config_sensors: # Use all sensors by default + config_sensors = [s.name for s in sensor_def] + used_sensors = list(set(config_sensors + list(config[CONF_CUSTOM].keys()))) + for sensor in used_sensors: + hass_sensors.append(SMAsensor(sensor_def[sensor], [])) async_add_entities(hass_sensors) - used_sensors = [sensor_def[s] for s in set(used_sensors)] # Init the SMA interface session = async_get_clientsession(hass, verify_ssl=config[CONF_VERIFY_SSL]) @@ -172,7 +207,7 @@ class SMAsensor(Entity): def __init__(self, pysma_sensor, sub_sensors): """Initialize the sensor.""" self._sensor = pysma_sensor - self._sub_sensors = sub_sensors + self._sub_sensors = sub_sensors # Can be remove from 0.99 self._attr = {s.name: "" for s in sub_sensors} self._state = self._sensor.value @@ -193,7 +228,7 @@ def unit_of_measurement(self): return self._sensor.unit @property - def device_state_attributes(self): + def device_state_attributes(self): # Can be remove from 0.99 """Return the state attributes of the sensor.""" return self._attr @@ -206,7 +241,7 @@ def async_update_values(self): """Update this sensor.""" update = False - for sens in self._sub_sensors: + for sens in self._sub_sensors: # Can be remove from 0.99 newval = "{} {}".format(sens.value, sens.unit) if self._attr[sens.name] != newval: update = True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index a68c8293a9fbc..3b60cb6616501 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure SMHI component.""" import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -21,7 +21,7 @@ def smhi_locations(hass: HomeAssistant): @config_entries.HANDLERS.register(DOMAIN) -class SmhiFlowHandler(data_entry_flow.FlowHandler): +class SmhiFlowHandler(config_entries.ConfigFlow): """Config flow for SMHI component.""" VERSION = 1 diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index a4d461f289f24..86e3062133457 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_MUSIC, + MEDIA_TYPE_PLAYLIST, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, @@ -938,20 +939,35 @@ def play_media(self, media_type, media_id, **kwargs): """ Send the play_media command to the media player. + If media_type is "playlist", media_id should be a Sonos + Playlist name. Otherwise, media_id should be a URI. + If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ - if kwargs.get(ATTR_MEDIA_ENQUEUE): + if media_type == MEDIA_TYPE_MUSIC: + if kwargs.get(ATTR_MEDIA_ENQUEUE): + try: + self.soco.add_uri_to_queue(media_id) + except SoCoUPnPException: + _LOGGER.error( + 'Error parsing media uri "%s", ' + "please check it's a valid media resource " + "supported by Sonos", + media_id, + ) + else: + self.soco.play_uri(media_id) + elif media_type == MEDIA_TYPE_PLAYLIST: try: - self.soco.add_uri_to_queue(media_id) - except SoCoUPnPException: - _LOGGER.error( - 'Error parsing media uri "%s", ' - "please check it's a valid media resource " - "supported by Sonos", - media_id, - ) + playlists = self.soco.get_sonos_playlists() + playlist = next(p for p in playlists if p.title == media_id) + self.soco.clear_queue() + self.soco.add_to_queue(playlist) + self.soco.play_from_queue(0) + except StopIteration: + _LOGGER.error('Could not find a Sonos playlist named "%s"', media_id) else: - self.soco.play_uri(media_id) + _LOGGER.error('Sonos does not support a media type of "%s"', media_type) @soco_error() def join(self, slaves): diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 252a29591c97d..51868c6d0a85f 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -82,10 +82,7 @@ def __init__(self, entity_id, name, sampling_size, max_age, precision): """Initialize the Statistics sensor.""" self._entity_id = entity_id self.is_binary = self._entity_id.split(".")[0] == "binary_sensor" - if not self.is_binary: - self._name = "{} {}".format(name, ATTR_MEAN) - else: - self._name = "{} {}".format(name, ATTR_COUNT) + self._name = name self._sampling_size = sampling_size self._max_age = max_age self._precision = precision diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index c9bd486053e0b..68561d45f8ff8 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -198,7 +198,7 @@ async def async_service_handler(service): return if service.service == "write": logger = logging.getLogger( - service.data.get(CONF_LOGGER, "{}.external".format(__name__)) + service.data.get(CONF_LOGGER, f"{__name__}.external") ) level = service.data[CONF_LEVEL] getattr(logger, level)(service.data[CONF_MESSAGE]) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 8b354f4eeb2b5..e0fc867720010 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -1,5 +1,6 @@ """Support for exposing a templated binary sensor.""" import logging +from itertools import chain import voluptuous as vol @@ -30,12 +31,14 @@ CONF_DELAY_ON = "delay_on" CONF_DELAY_OFF = "delay_off" +CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" SENSOR_SCHEMA = vol.Schema( { vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTE_TEMPLATES): vol.Schema({cv.string: cv.template}), vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, @@ -59,14 +62,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entity_picture_template = device_config.get(CONF_ENTITY_PICTURE_TEMPLATE) entity_ids = set() manual_entity_ids = device_config.get(ATTR_ENTITY_ID) + attribute_templates = device_config.get(CONF_ATTRIBUTE_TEMPLATES, {}) invalid_templates = [] - for tpl_name, template in ( - (CONF_VALUE_TEMPLATE, value_template), - (CONF_ICON_TEMPLATE, icon_template), - (CONF_ENTITY_PICTURE_TEMPLATE, entity_picture_template), - ): + templates = { + CONF_VALUE_TEMPLATE: value_template, + CONF_ICON_TEMPLATE: icon_template, + CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template, + } + + for tpl_name, template in chain(templates.items(), attribute_templates.items()): if template is None: continue template.hass = hass @@ -78,7 +84,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if template_entity_ids == MATCH_ALL: entity_ids = MATCH_ALL # Cut off _template from name - invalid_templates.append(tpl_name[:-9]) + invalid_templates.append(tpl_name.replace("_template", "")) elif entity_ids != MATCH_ALL: entity_ids |= set(template_entity_ids) @@ -114,6 +120,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entity_ids, delay_on, delay_off, + attribute_templates, ) ) if not sensors: @@ -139,6 +146,7 @@ def __init__( entity_ids, delay_on, delay_off, + attribute_templates, ): """Initialize the Template binary sensor.""" self.hass = hass @@ -154,6 +162,8 @@ def __init__( self._entities = entity_ids self._delay_on = delay_on self._delay_off = delay_off + self._attribute_templates = attribute_templates + self._attributes = {} async def async_added_to_hass(self): """Register callbacks.""" @@ -203,6 +213,11 @@ def device_class(self): """Return the sensor class of the sensor.""" return self._device_class + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + @property def should_poll(self): """No polling needed.""" @@ -225,10 +240,21 @@ def _async_render(self): return _LOGGER.error("Could not render template %s: %s", self._name, ex) - for property_name, template in ( - ("_icon", self._icon_template), - ("_entity_picture", self._entity_picture_template), - ): + templates = { + "_icon": self._icon_template, + "_entity_picture": self._entity_picture_template, + } + + attrs = {} + if self._attribute_templates is not None: + for key, value in self._attribute_templates.items(): + try: + attrs[key] = value.async_render() + except TemplateError as err: + _LOGGER.error("Error rendering attribute %s: %s", key, err) + self._attributes = attrs + + for property_name, template in templates.items(): if template is None: continue diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 9cbd349addcb7..f5bd981bad1dc 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -3,10 +3,11 @@ "name": "Tensorflow", "documentation": "https://www.home-assistant.io/components/tensorflow", "requirements": [ + "tensorflow==1.13.2", "numpy==1.17.0", "pillow==6.1.0", "protobuf==3.6.1" ], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/traccar/strings.json b/homeassistant/components/traccar/strings.json new file mode 100644 index 0000000000000..19f4eb0da2285 --- /dev/null +++ b/homeassistant/components/traccar/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Traccar", + "step": { + "user": { + "title": "Set up Traccar", + "description": "Are you sure you want to set up Traccar?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Traccar." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup the webhook feature in Traccar.\n\nUse the following url: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." + } + } +} diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 77d24fd7aab2a..3e7900502d6c3 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -165,9 +165,7 @@ async def async_say_handle(service): DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True ) - service_name = p_config.get( - CONF_SERVICE_NAME, "{}_{}".format(p_type, SERVICE_SAY) - ) + service_name = p_config.get(CONF_SERVICE_NAME, f"{p_type}_{SERVICE_SAY}") hass.services.async_register( DOMAIN, service_name, async_say_handle, schema=SCHEMA_SERVICE_SAY ) @@ -229,7 +227,7 @@ def init_tts_cache_dir(cache_dir): init_tts_cache_dir, cache_dir ) except OSError as err: - raise HomeAssistantError("Can't init cache dir {}".format(err)) + raise HomeAssistantError(f"Can't init cache dir {err}") def get_cache_files(): """Return a dict of given engine files.""" @@ -251,7 +249,7 @@ def get_cache_files(): try: cache_files = await self.hass.async_add_job(get_cache_files) except OSError as err: - raise HomeAssistantError("Can't read cache dir {}".format(err)) + raise HomeAssistantError(f"Can't read cache dir {err}") if cache_files: self.file_cache.update(cache_files) @@ -293,7 +291,7 @@ async def async_get_url( # Languages language = language or provider.default_language if language is None or language not in provider.supported_languages: - raise HomeAssistantError("Not supported language {0}".format(language)) + raise HomeAssistantError(f"Not supported language {language}") # Options if provider.default_options and options: @@ -308,9 +306,7 @@ async def async_get_url( if opt_name not in (provider.supported_options or []) ] if invalid_opts: - raise HomeAssistantError( - "Invalid options found: {}".format(invalid_opts) - ) + raise HomeAssistantError(f"Invalid options found: {invalid_opts}") options_key = ctypes.c_size_t(hash(frozenset(options))).value else: options_key = "-" @@ -330,7 +326,7 @@ async def async_get_url( engine, key, message, use_cache, language, options ) - return "{}/api/tts_proxy/{}".format(self.base_url, filename) + return f"{self.base_url}/api/tts_proxy/{filename}" async def async_get_tts_audio(self, engine, key, message, cache, language, options): """Receive TTS and store for view in cache. @@ -341,10 +337,10 @@ async def async_get_tts_audio(self, engine, key, message, cache, language, optio extension, data = await provider.async_get_tts_audio(message, language, options) if data is None or extension is None: - raise HomeAssistantError("No TTS from {} for '{}'".format(engine, message)) + raise HomeAssistantError(f"No TTS from {engine} for '{message}'") # Create file infos - filename = ("{}.{}".format(key, extension)).lower() + filename = (f"{key}.{extension}").lower() data = self.write_tags(filename, data, provider, message, language, options) @@ -381,7 +377,7 @@ async def async_file_to_mem(self, key): """ filename = self.file_cache.get(key) if not filename: - raise HomeAssistantError("Key {} not in file cache!".format(key)) + raise HomeAssistantError(f"Key {key} not in file cache!") voice_file = os.path.join(self.cache_dir, filename) @@ -394,7 +390,7 @@ def load_speech(): data = await self.hass.async_add_job(load_speech) except OSError: del self.file_cache[key] - raise HomeAssistantError("Can't read {}".format(voice_file)) + raise HomeAssistantError(f"Can't read {voice_file}") self._async_store_to_memcache(key, filename, data) @@ -425,7 +421,7 @@ async def async_read_tts(self, filename): if key not in self.mem_cache: if key not in self.file_cache: - raise HomeAssistantError("{} not in cache!".format(key)) + raise HomeAssistantError(f"{key} not in cache!") await self.async_file_to_mem(key) content, _ = mimetypes.guess_type(filename) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 57eb3f17584da..8d47d8a0173ba 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -3,7 +3,7 @@ "name": "Tuya", "documentation": "https://www.home-assistant.io/components/tuya", "requirements": [ - "tuyaha==0.0.2" + "tuyaha==0.0.3" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json index 3686148fdb645..c484bfbf09fa5 100644 --- a/homeassistant/components/unifi/.translations/en.json +++ b/homeassistant/components/unifi/.translations/en.json @@ -1,26 +1,41 @@ { "config": { - "abort": { - "already_configured": "Controller site is already configured", - "user_privilege": "User needs to be administrator" - }, - "error": { - "faulty_credentials": "Bad user credentials", - "service_unavailable": "No service available" - }, + "title": "UniFi Controller", "step": { "user": { + "title": "Set up UniFi Controller", "data": { "host": "Host", + "username": "User name", "password": "Password", "port": "Port", "site": "Site ID", - "username": "User name", "verify_ssl": "Controller using proper certificate" - }, - "title": "Set up UniFi Controller" + } } }, - "title": "UniFi Controller" + "error": { + "faulty_credentials": "Bad user credentials", + "service_unavailable": "No service available" + }, + "abort": { + "already_configured": "Controller site is already configured", + "user_privilege": "User needs to be administrator" + } + }, + "options": { + "step": { + "init": { + "data": {} + }, + "device_tracker": { + "data": { + "detection_time": "Time in seconds from last seen until considered away", + "track_clients": "Track network clients", + "track_devices": "Track network devices (Ubiquiti devices)", + "track_wired_clients": "Include wired network clients" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 4ca6f68c301f6..da9bbb8e59e98 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -11,9 +11,6 @@ CONF_BLOCK_CLIENT, CONF_CONTROLLER, CONF_DETECTION_TIME, - CONF_DONT_TRACK_CLIENTS, - CONF_DONT_TRACK_DEVICES, - CONF_DONT_TRACK_WIRED_CLIENTS, CONF_SITE_ID, CONF_SSID_FILTER, CONTROLLER_ID, @@ -23,6 +20,9 @@ from .controller import UniFiController CONF_CONTROLLERS = "controllers" +CONF_DONT_TRACK_CLIENTS = "dont_track_clients" +CONF_DONT_TRACK_DEVICES = "dont_track_devices" +CONF_DONT_TRACK_WIRED_CLIENTS = "dont_track_wired_clients" CONTROLLER_SCHEMA = vol.Schema( { @@ -34,9 +34,7 @@ vol.Optional(CONF_DONT_TRACK_CLIENTS): cv.boolean, vol.Optional(CONF_DONT_TRACK_DEVICES): cv.boolean, vol.Optional(CONF_DONT_TRACK_WIRED_CLIENTS): cv.boolean, - vol.Optional(CONF_DETECTION_TIME): vol.All( - cv.time_period, cv.positive_timedelta - ), + vol.Optional(CONF_DETECTION_TIME): cv.positive_int, vol.Optional(CONF_SSID_FILTER): vol.All(cv.ensure_list, [cv.string]), } ) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index e5a8965dff9d6..e1f0a91c774eb 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -2,6 +2,7 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -10,7 +11,20 @@ CONF_VERIFY_SSL, ) -from .const import CONF_CONTROLLER, CONF_SITE_ID, DOMAIN, LOGGER +from .const import ( + CONF_CONTROLLER, + CONF_TRACK_CLIENTS, + CONF_TRACK_DEVICES, + CONF_TRACK_WIRED_CLIENTS, + CONF_DETECTION_TIME, + CONF_SITE_ID, + DEFAULT_TRACK_CLIENTS, + DEFAULT_TRACK_DEVICES, + DEFAULT_TRACK_WIRED_CLIENTS, + DEFAULT_DETECTION_TIME, + DOMAIN, + LOGGER, +) from .controller import get_controller from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect @@ -26,6 +40,12 @@ class UnifiFlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return UnifiOptionsFlowHandler(config_entry) + def __init__(self): """Initialize the UniFi flow.""" self.config = None @@ -142,3 +162,52 @@ async def async_step_import(self, import_config): self.desc = import_config[CONF_SITE_ID] return await self.async_step_user(user_input=config) + + +class UnifiOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Unifi options.""" + + def __init__(self, config_entry): + """Initialize UniFi options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the UniFi options.""" + return await self.async_step_device_tracker() + + async def async_step_device_tracker(self, user_input=None): + """Manage the device tracker options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="device_tracker", + data_schema=vol.Schema( + { + vol.Optional( + CONF_TRACK_CLIENTS, + default=self.config_entry.options.get( + CONF_TRACK_CLIENTS, DEFAULT_TRACK_CLIENTS + ), + ): bool, + vol.Optional( + CONF_TRACK_WIRED_CLIENTS, + default=self.config_entry.options.get( + CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS + ), + ): bool, + vol.Optional( + CONF_TRACK_DEVICES, + default=self.config_entry.options.get( + CONF_TRACK_DEVICES, DEFAULT_TRACK_DEVICES + ), + ): bool, + vol.Optional( + CONF_DETECTION_TIME, + default=self.config_entry.options.get( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ), + ): int, + } + ), + ) diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index b4864421cb906..ffa9a28818bf5 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -13,9 +13,16 @@ CONF_BLOCK_CLIENT = "block_client" CONF_DETECTION_TIME = "detection_time" -CONF_DONT_TRACK_CLIENTS = "dont_track_clients" -CONF_DONT_TRACK_DEVICES = "dont_track_devices" -CONF_DONT_TRACK_WIRED_CLIENTS = "dont_track_wired_clients" +CONF_TRACK_CLIENTS = "track_clients" +CONF_TRACK_DEVICES = "track_devices" +CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" CONF_SSID_FILTER = "ssid_filter" +DEFAULT_BLOCK_CLIENTS = [] +DEFAULT_TRACK_CLIENTS = True +DEFAULT_TRACK_DEVICES = True +DEFAULT_TRACK_WIRED_CLIENTS = True +DEFAULT_DETECTION_TIME = 300 +DEFAULT_SSID_FILTER = [] + ATTR_MANUFACTURER = "Ubiquiti Networks" diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index cb82e6cf1c1d0..47c692b12b24a 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -1,4 +1,6 @@ """UniFi Controller abstraction.""" +from datetime import timedelta + import asyncio import ssl import async_timeout @@ -15,8 +17,19 @@ from .const import ( CONF_BLOCK_CLIENT, CONF_CONTROLLER, + CONF_DETECTION_TIME, + CONF_TRACK_CLIENTS, + CONF_TRACK_DEVICES, + CONF_TRACK_WIRED_CLIENTS, CONF_SITE_ID, + CONF_SSID_FILTER, CONTROLLER_ID, + DEFAULT_BLOCK_CLIENTS, + DEFAULT_TRACK_CLIENTS, + DEFAULT_TRACK_DEVICES, + DEFAULT_TRACK_WIRED_CLIENTS, + DEFAULT_DETECTION_TIME, + DEFAULT_SSID_FILTER, LOGGER, UNIFI_CONFIG, ) @@ -59,9 +72,40 @@ def site_role(self): return self._site_role @property - def block_clients(self): - """Return list of clients to block.""" - return self.unifi_config.get(CONF_BLOCK_CLIENT, []) + def option_block_clients(self): + """Config entry option with list of clients to control network access.""" + return self.config_entry.options.get(CONF_BLOCK_CLIENT, DEFAULT_BLOCK_CLIENTS) + + @property + def option_track_clients(self): + """Config entry option to not track clients.""" + return self.config_entry.options.get(CONF_TRACK_CLIENTS, DEFAULT_TRACK_CLIENTS) + + @property + def option_track_devices(self): + """Config entry option to not track devices.""" + return self.config_entry.options.get(CONF_TRACK_DEVICES, DEFAULT_TRACK_DEVICES) + + @property + def option_track_wired_clients(self): + """Config entry option to not track wired clients.""" + return self.config_entry.options.get( + CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS + ) + + @property + def option_detection_time(self): + """Config entry option defining number of seconds from last seen to away.""" + return timedelta( + seconds=self.config_entry.options.get( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ) + ) + + @property + def option_ssid_filter(self): + """Config entry option listing what SSIDs are being used to track clients.""" + return self.config_entry.options.get(CONF_SSID_FILTER, DEFAULT_SSID_FILTER) @property def mac(self): @@ -96,7 +140,7 @@ async def async_update(self): with async_timeout.timeout(10): await self.api.clients.update() await self.api.devices.update() - if self.block_clients: + if self.option_block_clients: await self.api.clients_all.update() except aiounifi.LoginRequired: @@ -155,6 +199,30 @@ async def async_setup(self): self.unifi_config = unifi_config break + options = dict(self.config_entry.options) + + if CONF_BLOCK_CLIENT in self.unifi_config: + options[CONF_BLOCK_CLIENT] = self.unifi_config[CONF_BLOCK_CLIENT] + + if CONF_TRACK_CLIENTS in self.unifi_config: + options[CONF_TRACK_CLIENTS] = self.unifi_config[CONF_TRACK_CLIENTS] + + if CONF_TRACK_DEVICES in self.unifi_config: + options[CONF_TRACK_DEVICES] = self.unifi_config[CONF_TRACK_DEVICES] + + if CONF_TRACK_WIRED_CLIENTS in self.unifi_config: + options[CONF_TRACK_WIRED_CLIENTS] = self.unifi_config[ + CONF_TRACK_WIRED_CLIENTS + ] + + if CONF_DETECTION_TIME in self.unifi_config: + options[CONF_DETECTION_TIME] = self.unifi_config[CONF_DETECTION_TIME] + + if CONF_SSID_FILTER in self.unifi_config: + options[CONF_SSID_FILTER] = self.unifi_config[CONF_SSID_FILTER] + + hass.config_entries.async_update_entry(self.config_entry, options=options) + for platform in ["device_tracker", "switch"]: hass.async_create_task( hass.config_entries.async_forward_entry_setup( diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 491a032e1cccf..c8024808e390e 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -27,12 +27,7 @@ from .const import ( ATTR_MANUFACTURER, CONF_CONTROLLER, - CONF_DETECTION_TIME, - CONF_DONT_TRACK_CLIENTS, - CONF_DONT_TRACK_DEVICES, - CONF_DONT_TRACK_WIRED_CLIENTS, CONF_SITE_ID, - CONF_SSID_FILTER, CONTROLLER_ID, DOMAIN as UNIFI_DOMAIN, ) @@ -151,11 +146,11 @@ def update_items(controller, async_add_entities, tracked): """Update tracked device state from the controller.""" new_tracked = [] - if not controller.unifi_config.get(CONF_DONT_TRACK_CLIENTS, False): + if controller.option_track_clients: for client_id in controller.api.clients: - if client_id in tracked: + if client_id in tracked and tracked[client_id].entity_id: LOGGER.debug( "Updating UniFi tracked client %s (%s)", tracked[client_id].entity_id, @@ -168,15 +163,12 @@ def update_items(controller, async_add_entities, tracked): if ( not client.is_wired - and CONF_SSID_FILTER in controller.unifi_config - and client.essid not in controller.unifi_config[CONF_SSID_FILTER] + and controller.option_ssid_filter + and client.essid not in controller.option_ssid_filter ): continue - if ( - controller.unifi_config.get(CONF_DONT_TRACK_WIRED_CLIENTS, False) - and client.is_wired - ): + if not controller.option_track_wired_clients and client.is_wired: continue tracked[client_id] = UniFiClientTracker(client, controller) @@ -187,11 +179,11 @@ def update_items(controller, async_add_entities, tracked): client.mac, ) - if not controller.unifi_config.get(CONF_DONT_TRACK_DEVICES, False): + if controller.option_track_devices: for device_id in controller.api.devices: - if device_id in tracked: + if device_id in tracked and tracked[device_id].entity_id: LOGGER.debug( "Updating UniFi tracked device %s (%s)", tracked[device_id].entity_id, @@ -229,14 +221,11 @@ async def async_update(self): @property def is_connected(self): """Return true if the client is connected to the network.""" - detection_time = self.controller.unifi_config.get( - CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME - ) - if ( dt_util.utcnow() - dt_util.utc_from_timestamp(float(self.client.last_seen)) - ) < detection_time: + ) < self.controller.option_detection_time: return True + return False @property @@ -291,15 +280,12 @@ async def async_update(self): @property def is_connected(self): """Return true if the device is connected to the network.""" - detection_time = self.controller.unifi_config.get( - CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME - ) - if self.device.state == 1 and ( dt_util.utcnow() - dt_util.utc_from_timestamp(float(self.device.last_seen)) - < detection_time + < self.controller.option_detection_time ): return True + return False @property diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 938ac058d22a6..c484bfbf09fa5 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -22,5 +22,20 @@ "already_configured": "Controller site is already configured", "user_privilege": "User needs to be administrator" } + }, + "options": { + "step": { + "init": { + "data": {} + }, + "device_tracker": { + "data": { + "detection_time": "Time in seconds from last seen until considered away", + "track_clients": "Track network clients", + "track_devices": "Track network devices (Ubiquiti devices)", + "track_wired_clients": "Include wired network clients" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 2b7965d1095b1..b7bb9b730ada2 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -74,7 +74,7 @@ def update_items(controller, async_add_entities, switches, switches_off): devices = controller.api.devices # block client - for client_id in controller.block_clients: + for client_id in controller.option_block_clients: block_client_id = "block-{}".format(client_id) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c09c43dc2826b..17eacc326d3b5 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -98,7 +98,7 @@ async def async_setup(hass, config): tariff_confs.append( { CONF_METER: meter, - CONF_NAME: "{} {}".format(meter, tariff), + CONF_NAME: f"{meter} {tariff}", CONF_TARIFF: tariff, } ) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 1eceaea2ae54f..1ad4300b28b74 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -107,7 +107,7 @@ def __init__( if name: self._name = name else: - self._name = "{} meter".format(source_entity) + self._name = f"{source_entity} meter" self._unit_of_measurement = None self._period = meter_type self._period_offset = meter_offset diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index e75ee2387ee68..7e1ae1ecd60b0 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -265,9 +265,11 @@ def set_temperature(self, **kwargs): elif operation_mode == self._client.MODE_AUTO: success = self._client.set_setpoints(temp_low, temp_high) else: + success = False _LOGGER.error( "The thermostat is currently not in a mode " - "that supports target temperature" + "that supports target temperature: %s", + operation_mode, ) if not success: diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 8f276279ee5df..fd122f66ac2b2 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -125,11 +125,11 @@ def precision(self): @property def state_attributes(self): """Return the state attributes.""" - data = { - ATTR_WEATHER_TEMPERATURE: show_temp( + data = {} + if self.temperature is not None: + data[ATTR_WEATHER_TEMPERATURE] = show_temp( self.hass, self.temperature, self.temperature_unit, self.precision ) - } humidity = self.humidity if humidity is not None: diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 2ed03b73eff94..af107a6ae0df1 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -33,7 +33,7 @@ def setup(hass, config): """Set up Zeroconf and make Home Assistant discoverable.""" - zeroconf_name = "{}.{}".format(hass.config.location_name, ZEROCONF_TYPE) + zeroconf_name = f"{hass.config.location_name}.{ZEROCONF_TYPE}" params = { "version": __version__, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4c45b59aebb25..0e00489303322 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/components/zha", "requirements": [ - "bellows-homeassistant==0.9.0", - "zha-quirks==0.0.20", - "zigpy-deconz==0.2.1", + "bellows-homeassistant==0.9.1", + "zha-quirks==0.0.21", + "zigpy-deconz==0.2.2", "zigpy-homeassistant==0.7.1", "zigpy-xbee-homeassistant==0.4.0", "zigpy-zigate==0.1.0" diff --git a/homeassistant/config.py b/homeassistant/config.py index 1f42b3db25e94..f4775e718054e 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -317,7 +317,7 @@ def _load_hass_yaml_config() -> Dict: path = find_config_file(hass.config.config_dir) if path is None: raise HomeAssistantError( - "Config file not found in: {}".format(hass.config.config_dir) + f"Config file not found in: {hass.config.config_dir}" ) config = load_yaml_config_file(path) return config @@ -443,7 +443,7 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: This method must be run in the event loop. """ - message = "Invalid config for [{}]: ".format(domain) + message = f"Invalid config for [{domain}]: " if "extra keys not allowed" in ex.error_message: message += ( "[{option}] is an invalid option for [{domain}]. " @@ -705,7 +705,7 @@ async def merge_packages_config( error = _recursive_merge(conf=config[comp_name], package=comp_conf) if error: _log_pkg_error( - pack_name, comp_name, config, "has duplicate key '{}'".format(error) + pack_name, comp_name, config, f"has duplicate key '{error}'" ) return config @@ -777,7 +777,7 @@ async def async_process_component_config( p_config ) except vol.Invalid as ex: - async_log_exception(ex, "{}.{}".format(domain, p_name), p_config, hass) + async_log_exception(ex, f"{domain}.{p_name}", p_config, hass) continue platforms.append(p_validated) @@ -836,7 +836,7 @@ def async_notify_setup_error( else: part = name - message += " - {}\n".format(part) + message += f" - {part}\n" message += "\nPlease check your config." diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9844aeb9ca679..c2da37943c1ab 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3,13 +3,7 @@ import logging import functools import uuid -from typing import ( - Any, - Callable, - List, - Optional, - Set, # noqa pylint: disable=unused-import -) +from typing import Any, Callable, List, Optional, Set import weakref import attr @@ -19,6 +13,7 @@ from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady from homeassistant.setup import async_setup_component, async_process_deps_reqs from homeassistant.util.decorator import Registry +from homeassistant.helpers import entity_registry # mypy: allow-untyped-defs @@ -161,8 +156,6 @@ async def async_setup( try: component = integration.get_component() - if self.domain == integration.domain: - integration.get_platform("config_flow") except ImportError as err: _LOGGER.error( "Error importing integration %s to set up %s config entry: %s", @@ -174,8 +167,20 @@ async def async_setup( self.state = ENTRY_STATE_SETUP_ERROR return - # Perform migration - if integration.domain == self.domain: + if self.domain == integration.domain: + try: + integration.get_platform("config_flow") + except ImportError as err: + _LOGGER.error( + "Error importing platform config_flow from integration %s to set up %s config entry: %s", + integration.domain, + self.domain, + err, + ) + self.state = ENTRY_STATE_SETUP_ERROR + return + + # Perform migration if not await self.async_migrate(hass): self.state = ENTRY_STATE_MIGRATION_ERROR return @@ -383,6 +388,7 @@ def __init__(self, hass: HomeAssistant, hass_config: dict) -> None: self._hass_config = hass_config self._entries = [] # type: List[ConfigEntry] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + EntityRegistryDisabledHandler(hass).async_setup() @callback def async_domains(self) -> List[str]: @@ -522,7 +528,9 @@ async def async_reload(self, entry_id: str) -> bool: return await self.async_setup(entry_id) @callback - def async_update_entry(self, entry, *, data=_UNDEF, options=_UNDEF): + def async_update_entry( + self, entry, *, data=_UNDEF, options=_UNDEF, system_options=_UNDEF + ): """Update a config entry.""" if data is not _UNDEF: entry.data = data @@ -530,10 +538,12 @@ def async_update_entry(self, entry, *, data=_UNDEF, options=_UNDEF): if options is not _UNDEF: entry.options = options - if data is not _UNDEF or options is not _UNDEF: - for listener_ref in entry.update_listeners: - listener = listener_ref() - self.hass.async_create_task(listener(self.hass, entry)) + if system_options is not _UNDEF: + entry.system_options.update(**system_options) + + for listener_ref in entry.update_listeners: + listener = listener_ref() + self.hass.async_create_task(listener(self.hass, entry)) self._async_schedule_save() @@ -666,6 +676,12 @@ async def _old_conf_migrator(old_config): class ConfigFlow(data_entry_flow.FlowHandler): """Base class for config flows with some helpers.""" + def __init_subclass__(cls, domain=None, **kwargs): + """Initialize a subclass, register if possible.""" + super().__init_subclass__(**kwargs) # type: ignore + if domain is not None: + HANDLERS.register(domain)(cls) + CONNECTION_CLASS = CONN_CLASS_UNKNOWN @staticmethod @@ -747,3 +763,91 @@ def update(self, *, disable_new_entities): def as_dict(self): """Return dictionary version of this config entrys system options.""" return {"disable_new_entities": self.disable_new_entities} + + +class EntityRegistryDisabledHandler: + """Handler to handle when entities related to config entries updating disabled_by.""" + + RELOAD_AFTER_UPDATE_DELAY = 30 + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the handler.""" + self.hass = hass + self.registry: Optional[entity_registry.EntityRegistry] = None + self.changed: Set[str] = set() + self._remove_call_later: Optional[Callable[[], None]] = None + + @callback + def async_setup(self) -> None: + """Set up the disable handler.""" + self.hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entry_updated + ) + + async def _handle_entry_updated(self, event): + """Handle entity registry entry update.""" + if ( + event.data["action"] != "update" + or "disabled_by" not in event.data["changes"] + ): + return + + if self.registry is None: + self.registry = await entity_registry.async_get_registry(self.hass) + + entity_entry = self.registry.async_get(event.data["entity_id"]) + + if ( + # Stop if no entry found + entity_entry is None + # Stop if entry not connected to config entry + or entity_entry.config_entry_id is None + # Stop if the entry got disabled. In that case the entity handles it + # themselves. + or entity_entry.disabled_by + ): + return + + config_entry = self.hass.config_entries.async_get_entry( + entity_entry.config_entry_id + ) + + if config_entry.entry_id not in self.changed and await support_entry_unload( + self.hass, config_entry.domain + ): + self.changed.add(config_entry.entry_id) + + if not self.changed: + return + + # We are going to delay reloading on *every* entity registry change so that + # if a user is happily clicking along, it will only reload at the end. + + if self._remove_call_later: + self._remove_call_later() + + self._remove_call_later = self.hass.helpers.event.async_call_later( + self.RELOAD_AFTER_UPDATE_DELAY, self._handle_reload + ) + + async def _handle_reload(self, _now): + """Handle a reload.""" + self._remove_call_later = None + to_reload = self.changed + self.changed = set() + + _LOGGER.info( + "Reloading config entries because disabled_by changed in entity registry: %s", + ", ".join(self.changed), + ) + + await asyncio.gather( + *[self.hass.config_entries.async_reload(entry_id) for entry_id in to_reload] + ) + + +async def support_entry_unload(hass: HomeAssistant, domain: str) -> bool: + """Test if a domain supports entry unloading.""" + integration = await loader.async_get_integration(hass, domain) + component = integration.get_component() + return hasattr(component, "async_unload_entry") diff --git a/homeassistant/core.py b/homeassistant/core.py index a205aa401a6a5..4d7596d667b10 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -276,7 +276,7 @@ async def async_start(self) -> None: self.state = CoreState.running _async_create_timer(self) - def add_job(self, target: Callable[..., None], *args: Any) -> None: + def add_job(self, target: Callable[..., Any], *args: Any) -> None: """Add job to the executor pool. target: target to call. @@ -1365,7 +1365,7 @@ def set_time_zone(self, time_zone_str: str) -> None: self.time_zone = time_zone dt_util.set_default_time_zone(time_zone) else: - raise ValueError("Received invalid time zone {}".format(time_zone_str)) + raise ValueError(f"Received invalid time zone {time_zone_str}") @callback def _update( diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 0af6677dceb24..6bbd757fca63a 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -126,7 +126,7 @@ async def _async_handle_step( self, flow: Any, step_id: str, user_input: Optional[Dict] ) -> Dict: """Handle a step of a flow.""" - method = "async_step_{}".format(step_id) + method = f"async_step_{step_id}" if not hasattr(flow, method): self._progress.pop(flow.flow_id) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index dfb001ff0d797..89caf730ad7fa 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -25,7 +25,7 @@ class TemplateError(HomeAssistantError): def __init__(self, exception: jinja2.TemplateError) -> None: """Init the error.""" - super().__init__("{}: {}".format(exception.__class__.__name__, exception)) + super().__init__(f"{exception.__class__.__name__}: {exception}") class PlatformNotReady(HomeAssistantError): @@ -73,10 +73,10 @@ class ServiceNotFound(HomeAssistantError): def __init__(self, domain: str, service: str) -> None: """Initialize error.""" - super().__init__(self, "Service {}.{} not found".format(domain, service)) + super().__init__(self, f"Service {domain}.{service} not found") self.domain = domain self.service = service def __str__(self) -> str: """Return string representation.""" - return "Unable to find service {}/{}".format(self.domain, self.service) + return f"Unable to find service {self.domain}/{self.service}" diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index bc39d5d5720c5..f49ae9768272a 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -62,7 +62,7 @@ def _pack_error(package, component, config, message): message = "Package {} setup failed. Component {} {}".format( package, component, message ) - domain = "homeassistant.packages.{}.{}".format(package, component) + domain = f"homeassistant.packages.{package}.{component}" pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_error(message, domain, pack_config) @@ -77,9 +77,9 @@ def _comp_error(ex, domain, config): return result.add_error("File configuration.yaml not found.") config = await hass.async_add_executor_job(load_yaml_config_file, config_path) except FileNotFoundError: - return result.add_error("File not found: {}".format(config_path)) + return result.add_error(f"File not found: {config_path}") except HomeAssistantError as err: - return result.add_error("Error loading {}: {}".format(config_path, err)) + return result.add_error(f"Error loading {config_path}: {err}") finally: yaml_loader.clear_secret_cache() @@ -106,13 +106,13 @@ def _comp_error(ex, domain, config): try: integration = await async_get_integration_with_requirements(hass, domain) except (RequirementsNotFound, loader.IntegrationNotFound) as ex: - result.add_error("Component error: {} - {}".format(domain, ex)) + result.add_error(f"Component error: {domain} - {ex}") continue try: component = integration.get_component() except ImportError as ex: - result.add_error("Component error: {} - {}".format(domain, ex)) + result.add_error(f"Component error: {domain} - {ex}") continue config_schema = getattr(component, "CONFIG_SCHEMA", None) @@ -159,7 +159,7 @@ def _comp_error(ex, domain, config): RequirementsNotFound, ImportError, ) as ex: - result.add_error("Platform error {}.{} - {}".format(domain, p_name, ex)) + result.add_error(f"Platform error {domain}.{p_name} - {ex}") continue # Validate platform specific schema @@ -168,7 +168,7 @@ def _comp_error(ex, domain, config): try: p_validated = platform_schema(p_validated) except vol.Invalid as ex: - _comp_error(ex, "{}.{}".format(domain, p_name), p_validated) + _comp_error(ex, f"{domain}.{p_name}", p_validated) continue platforms.append(p_validated) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index aecdf45dde5f8..dc2e46cc6b22f 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -99,6 +99,9 @@ class Entity: # If we reported if this entity was slow _slow_reported = False + # If we reported this entity is updated while disabled + _disabled_reported = False + # Protect for multiple updates _update_staged = False @@ -240,11 +243,11 @@ async def async_update_ha_state(self, force_refresh=False): This method must be run in the event loop. """ if self.hass is None: - raise RuntimeError("Attribute hass is None for {}".format(self)) + raise RuntimeError(f"Attribute hass is None for {self}") if self.entity_id is None: raise NoEntitySpecifiedError( - "No entity id specified for entity {}".format(self.name) + f"No entity id specified for entity {self.name}" ) # update entity data @@ -261,11 +264,11 @@ async def async_update_ha_state(self, force_refresh=False): def async_write_ha_state(self): """Write the state to the state machine.""" if self.hass is None: - raise RuntimeError("Attribute hass is None for {}".format(self)) + raise RuntimeError(f"Attribute hass is None for {self}") if self.entity_id is None: raise NoEntitySpecifiedError( - "No entity id specified for entity {}".format(self.name) + f"No entity id specified for entity {self.name}" ) self._async_write_ha_state() @@ -273,6 +276,16 @@ def async_write_ha_state(self): @callback def _async_write_ha_state(self): """Write the state to the state machine.""" + if self.registry_entry and self.registry_entry.disabled_by: + if not self._disabled_reported: + self._disabled_reported = True + _LOGGER.warning( + "Entity %s is incorrectly being triggered for updates while it is disabled. This is a bug in the %s integration.", + self.entity_id, + self.platform.platform_name, + ) + return + start = timer() attr = {} @@ -490,6 +503,10 @@ async def _async_registry_updated(self, event): old = self.registry_entry self.registry_entry = ent_reg.async_get(data["entity_id"]) + if self.registry_entry.disabled_by is not None: + await self.async_remove() + return + if self.registry_entry.entity_id == old.entity_id: self.async_write_ha_state() return diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index b28beeaea72bb..a923763570285 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -205,7 +205,7 @@ def async_register_entity_service(self, name, schema, func, required_features=No async def handle_service(call): """Handle the service.""" - service_name = "{}.{}".format(self.domain, name) + service_name = f"{self.domain}.{name}" await self.hass.helpers.service.entity_service_call( self._platforms.values(), func, call, service_name, required_features ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 74351ac50af89..7d5debd484d4e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -133,7 +133,7 @@ async def _async_setup_platform(self, async_create_setup_task, tries=0): current_platform.set(self) logger = self.logger hass = self.hass - full_name = "{}.{}".format(self.domain, self.platform_name) + full_name = f"{self.domain}.{self.platform_name}" logger.info("Setting up %s", full_name) warn_task = hass.loop.call_later( @@ -349,18 +349,18 @@ async def _async_add_entity( disabled_by=disabled_by, ) + entity.registry_entry = entry + entity.entity_id = entry.entity_id + if entry.disabled: self.logger.info( "Not adding entity %s because it's disabled", entry.name or entity.name - or '"{} {}"'.format(self.platform_name, entity.unique_id), + or f'"{self.platform_name} {entity.unique_id}"', ) return - entity.registry_entry = entry - entity.entity_id = entry.entity_id - # We won't generate an entity ID if the platform has already set one # We will however make sure that platform cannot pick a registered ID elif entity.entity_id is not None and entity_registry.async_is_registered( @@ -386,12 +386,12 @@ async def _async_add_entity( # Make sure it is valid in case an entity set the value themselves if not valid_entity_id(entity.entity_id): - raise HomeAssistantError("Invalid entity id: {}".format(entity.entity_id)) + raise HomeAssistantError(f"Invalid entity id: {entity.entity_id}") if ( entity.entity_id in self.entities or entity.entity_id in self.hass.states.async_entity_ids(self.domain) ): - msg = "Entity id already exists: {}".format(entity.entity_id) + msg = f"Entity id already exists: {entity.entity_id}" if entity.unique_id is not None: msg += ". Platform {} does not generate unique IDs".format( self.platform_name diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3d84313a5c650..3be00c859a7a4 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -166,9 +166,7 @@ def async_get_or_create( ) entity_id = self.async_generate_entity_id( - domain, - suggested_object_id or "{}_{}".format(platform, unique_id), - known_object_ids, + domain, suggested_object_id or f"{platform}_{unique_id}", known_object_ids ) if ( @@ -302,7 +300,7 @@ def _async_update_entity( self.async_schedule_save() - data = {"action": "update", "entity_id": entity_id} + data = {"action": "update", "entity_id": entity_id, "changes": list(changes)} if old.entity_id != entity_id: data["old_entity_id"] = old.entity_id diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index ffd5918810f87..4fb0d94287c45 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -58,7 +58,7 @@ async def async_handle( handler = hass.data.get(DATA_KEY, {}).get(intent_type) # type: IntentHandler if handler is None: - raise UnknownIntent("Unknown intent {}".format(intent_type)) + raise UnknownIntent(f"Unknown intent {intent_type}") intent = Intent(hass, platform, intent_type, slots or {}, text_input) @@ -68,13 +68,11 @@ async def async_handle( return result except vol.Invalid as err: _LOGGER.warning("Received invalid slot info for %s: %s", intent_type, err) - raise InvalidSlotInfo( - "Received invalid slot info for {}".format(intent_type) - ) from err + raise InvalidSlotInfo(f"Received invalid slot info for {intent_type}") from err except IntentHandleError: raise except Exception as err: - raise IntentUnexpectedError("Error handling {}".format(intent_type)) from err + raise IntentUnexpectedError(f"Error handling {intent_type}") from err class IntentError(HomeAssistantError): @@ -109,7 +107,7 @@ def async_match_state( state = _fuzzymatch(name, states, lambda state: state.name) if state is None: - raise IntentHandleError("Unable to find an entity called {}".format(name)) + raise IntentHandleError(f"Unable to find an entity called {name}") return state @@ -118,9 +116,7 @@ def async_match_state( def async_test_feature(state: State, feature: int, feature_name: str) -> None: """Test is state supports a feature.""" if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0: - raise IntentHandleError( - "Entity {} does not support {}".format(state.name, feature_name) - ) + raise IntentHandleError(f"Entity {state.name} does not support {feature_name}") class IntentHandler: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 07e070df8c5ec..f29d1885d1e14 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -231,6 +231,20 @@ async def async_get_all_descriptions(hass): return descriptions +@ha.callback +@bind_hass +def async_set_service_schema(hass, domain, service, schema): + """Register a description for a service.""" + hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) + + description = { + "description": schema.get("description") or "", + "fields": schema.get("fields") or {}, + } + + hass.data[SERVICE_DESCRIPTION_CACHE]["{}.{}".format(domain, service)] = description + + @bind_hass async def entity_service_call( hass, platforms, func, call, service_name="", required_features=None diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py index 8b32b1355fa41..30b428a9e1799 100644 --- a/homeassistant/helpers/temperature.py +++ b/homeassistant/helpers/temperature.py @@ -20,7 +20,7 @@ def display_temp( # If the temperature is not a number this can cause issues # with Polymer components, so bail early there. if not isinstance(temperature, Number): - raise TypeError("Temperature is not a number: {}".format(temperature)) + raise TypeError(f"Temperature is not a number: {temperature}") # type ignore: https://github.com/python/mypy/issues/7207 if temperature_unit != ha_unit: # type: ignore diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ca320cb1c331b..98e3849bfb633 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -320,10 +320,10 @@ def __getattr__(self, name): """Return the domain state.""" if "." in name: if not valid_entity_id(name): - raise TemplateError("Invalid entity ID '{}'".format(name)) + raise TemplateError(f"Invalid entity ID '{name}'") return _get_state(self._hass, name) if not valid_entity_id(name + ".entity"): - raise TemplateError("Invalid domain name '{}'".format(name)) + raise TemplateError(f"Invalid domain name '{name}'") return DomainStates(self._hass, name) def _collect_all(self): @@ -367,9 +367,9 @@ def __init__(self, hass, domain): def __getattr__(self, name): """Return the states.""" - entity_id = "{}.{}".format(self._domain, name) + entity_id = f"{self._domain}.{name}" if not valid_entity_id(entity_id): - raise TemplateError("Invalid entity ID '{}'".format(entity_id)) + raise TemplateError(f"Invalid entity ID '{entity_id}'") return _get_state(self._hass, entity_id) def _collect_domain(self): @@ -399,7 +399,7 @@ def __len__(self): def __repr__(self): """Representation of Domain States.""" - return "